mirror of
https://github.com/penpot/penpot.git
synced 2025-12-24 06:58:34 -05:00
Compare commits
1132 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17798dbf40 | ||
|
|
4e1dfcce32 | ||
|
|
c28da17515 | ||
|
|
9f0e65a042 | ||
|
|
f1cf5d8ba8 | ||
|
|
cc682a382f | ||
|
|
1f98b168ba | ||
|
|
21430cbd7d | ||
|
|
f174264f7f | ||
|
|
6eaa905f0c | ||
|
|
1c23e4e8be | ||
|
|
e0ad6c0b95 | ||
|
|
f1d73d5662 | ||
|
|
bbe3021aed | ||
|
|
934c6c5aae | ||
|
|
7036dddad1 | ||
|
|
92ee6320f5 | ||
|
|
8a3c580d0f | ||
|
|
08a11929ca | ||
|
|
b460a8f64e | ||
|
|
1aa7960863 | ||
|
|
89edcb5651 | ||
|
|
653bc66b8f | ||
|
|
bec09fb5d1 | ||
|
|
9048c01308 | ||
|
|
959e069ea9 | ||
|
|
955bf0ef9e | ||
|
|
9a60ac477f | ||
|
|
ec131382b3 | ||
|
|
ea2e25b46d | ||
|
|
db7c4a9265 | ||
|
|
1b31a02c14 | ||
|
|
dcbf57d8d2 | ||
|
|
6e73e7cc71 | ||
|
|
44e31f1890 | ||
|
|
fb4ee4a355 | ||
|
|
d254184057 | ||
|
|
7e73ac307a | ||
|
|
f611584bb3 | ||
|
|
e1faba2ddc | ||
|
|
0f60f115f5 | ||
|
|
13560bc866 | ||
|
|
c670089c03 | ||
|
|
b1f0d09501 | ||
|
|
53b4c6383b | ||
|
|
e9819ab063 | ||
|
|
9b9f2c39b9 | ||
|
|
203b6c63a4 | ||
|
|
217ca66720 | ||
|
|
1106ebc377 | ||
|
|
9bcb3e9e7f | ||
|
|
6c13925930 | ||
|
|
39b46b3bc7 | ||
|
|
529ef75058 | ||
|
|
2977709468 | ||
|
|
c4ca40da16 | ||
|
|
a6818a8a55 | ||
|
|
a72e50f674 | ||
|
|
965c4fe243 | ||
|
|
13b1762873 | ||
|
|
ee73384993 | ||
|
|
a940c7e912 | ||
|
|
119b3a405c | ||
|
|
fc018b18b3 | ||
|
|
f57ed6a763 | ||
|
|
8b7f791509 | ||
|
|
369192a353 | ||
|
|
1b0a6b26ce | ||
|
|
fc35b0b853 | ||
|
|
872648d393 | ||
|
|
5631204567 | ||
|
|
9f121cb38b | ||
|
|
5072c903c5 | ||
|
|
66559d3ce3 | ||
|
|
7e0a612818 | ||
|
|
e9ce327eef | ||
|
|
491251f5ce | ||
|
|
65598aa724 | ||
|
|
e563611c05 | ||
|
|
a2d1ce8120 | ||
|
|
91037caa55 | ||
|
|
b94885a764 | ||
|
|
52545692df | ||
|
|
3dcd640a99 | ||
|
|
2e461b3070 | ||
|
|
41924246aa | ||
|
|
2b37a3c613 | ||
|
|
f30ba5876e | ||
|
|
23c8043f34 | ||
|
|
a6fc60a88d | ||
|
|
3c9d3bd5af | ||
|
|
8e1c4238cb | ||
|
|
2d57523e00 | ||
|
|
8e0c6da1d6 | ||
|
|
8007794cba | ||
|
|
8b81f700a5 | ||
|
|
ea753da0ae | ||
|
|
d1a7c58c53 | ||
|
|
e5a7edeaf6 | ||
|
|
d0a422e8bd | ||
|
|
7ea92529f9 | ||
|
|
494c585e2f | ||
|
|
02b41abaf8 | ||
|
|
a665339c98 | ||
|
|
9c0e594294 | ||
|
|
ad53d0b55a | ||
|
|
decaeda2fe | ||
|
|
60130d4db2 | ||
|
|
f85a9011ee | ||
|
|
9dbf6ffd14 | ||
|
|
992dd04b47 | ||
|
|
010a3ef3a7 | ||
|
|
3da0d85d8f | ||
|
|
7a837110f0 | ||
|
|
09d28d8583 | ||
|
|
90f5b4b631 | ||
|
|
52ad26d4e7 | ||
|
|
5c92ad727d | ||
|
|
7823a3270a | ||
|
|
b565e20f1a | ||
|
|
735170debf | ||
|
|
a2fbf93ec1 | ||
|
|
7b887d3188 | ||
|
|
c1dd4e5e6f | ||
|
|
7d7b4074b2 | ||
|
|
51462ba476 | ||
|
|
99693f0fc2 | ||
|
|
fdbabe49df | ||
|
|
996a614ed7 | ||
|
|
7a499bfc90 | ||
|
|
647beec1e8 | ||
|
|
dd9f637f02 | ||
|
|
00450565c8 | ||
|
|
cf9fb7face | ||
|
|
44514a0961 | ||
|
|
bfc490bd63 | ||
|
|
0a9cad76c3 | ||
|
|
26ef8df79c | ||
|
|
cd2f50fdb4 | ||
|
|
59d02314e2 | ||
|
|
88ac27788b | ||
|
|
c16de52b49 | ||
|
|
8d6d589a0c | ||
|
|
0817c4e140 | ||
|
|
aad70d9df8 | ||
|
|
bbcf9c00a5 | ||
|
|
49df4a9404 | ||
|
|
acfeae8638 | ||
|
|
7216a514e6 | ||
|
|
48d9541d46 | ||
|
|
01ec22d662 | ||
|
|
b43d09e5ce | ||
|
|
009236bbe3 | ||
|
|
0d87dc5680 | ||
|
|
8b0339bbab | ||
|
|
302bfd3007 | ||
|
|
302750bd7e | ||
|
|
66e32e9cbd | ||
|
|
e40245e187 | ||
|
|
16854e7e83 | ||
|
|
5a8df0dfae | ||
|
|
8f8d90abbc | ||
|
|
bf297539ae | ||
|
|
be652b909e | ||
|
|
068d2f13f4 | ||
|
|
1464f5da90 | ||
|
|
7b0d3bdcab | ||
|
|
5d42631c7a | ||
|
|
e0c0b251a9 | ||
|
|
a868dcf8e6 | ||
|
|
b64a9f0cf4 | ||
|
|
45a909f5ff | ||
|
|
dcc15e485d | ||
|
|
6849a5b0e0 | ||
|
|
ef3fedee59 | ||
|
|
8955f87d5a | ||
|
|
94b5c98042 | ||
|
|
82183ec71a | ||
|
|
e75b53ff8d | ||
|
|
9a880f007c | ||
|
|
02466d603c | ||
|
|
4d4e9703cc | ||
|
|
a737c125d5 | ||
|
|
e461745479 | ||
|
|
8cda8924df | ||
|
|
dda67af5cc | ||
|
|
cadcc1607d | ||
|
|
63c8798264 | ||
|
|
74dd4f1ff8 | ||
|
|
53cee87701 | ||
|
|
d939a86e75 | ||
|
|
050646506e | ||
|
|
6339b07fba | ||
|
|
e61aaaecf3 | ||
|
|
3ea5b1a8de | ||
|
|
17731db28b | ||
|
|
5b40fdf3f0 | ||
|
|
9ab067b6d8 | ||
|
|
2648dc3d27 | ||
|
|
9d06a34df4 | ||
|
|
1770bb995b | ||
|
|
85e1899f6b | ||
|
|
0716aaeff6 | ||
|
|
af114ee9d0 | ||
|
|
2249bf9745 | ||
|
|
c3c6112ade | ||
|
|
5ea80c018f | ||
|
|
287213cfaf | ||
|
|
51d829a4b3 | ||
|
|
f166fe1926 | ||
|
|
f60d09eb8f | ||
|
|
339903f567 | ||
|
|
7f16a79af5 | ||
|
|
97af5f71eb | ||
|
|
ba4ef66cdc | ||
|
|
7191fe847c | ||
|
|
dad13ed826 | ||
|
|
6cab413a8f | ||
|
|
a895eaf61c | ||
|
|
7977d75e3d | ||
|
|
7746649eb8 | ||
|
|
840801ea15 | ||
|
|
cacaf2bf95 | ||
|
|
4607d9f210 | ||
|
|
8f0a4e8333 | ||
|
|
ef5c9babe1 | ||
|
|
f75b111564 | ||
|
|
a8e058ada6 | ||
|
|
c988d54925 | ||
|
|
921ea61e6c | ||
|
|
71a6ee51fa | ||
|
|
b138550c0d | ||
|
|
81658c90d1 | ||
|
|
ca1e6c342f | ||
|
|
7feda98eb3 | ||
|
|
33e0e6293b | ||
|
|
2a81d8563a | ||
|
|
ae9d6b627d | ||
|
|
2db5925e60 | ||
|
|
d02f3ba011 | ||
|
|
74e8081574 | ||
|
|
1817d4ce38 | ||
|
|
433b1b68c3 | ||
|
|
776159c1e8 | ||
|
|
45e76bc38b | ||
|
|
54cee6ea72 | ||
|
|
0ae4988908 | ||
|
|
a97929992e | ||
|
|
a53176489a | ||
|
|
d8121364ad | ||
|
|
a66a952573 | ||
|
|
d4fe810813 | ||
|
|
10205e51cc | ||
|
|
0aefd044dc | ||
|
|
d11b007795 | ||
|
|
5af2489315 | ||
|
|
64ddfa0c31 | ||
|
|
6242c62bcb | ||
|
|
e8dde477a5 | ||
|
|
69969d9815 | ||
|
|
1b0848389c | ||
|
|
4f02cc3e86 | ||
|
|
749d60be48 | ||
|
|
a0535de30c | ||
|
|
bb8a523208 | ||
|
|
4d3e7f9a75 | ||
|
|
9bd658661d | ||
|
|
2edbc10851 | ||
|
|
5fc303a05d | ||
|
|
50bdad3450 | ||
|
|
9a45ce80a6 | ||
|
|
3645d1af20 | ||
|
|
d2bfd98a05 | ||
|
|
ecedf46c2a | ||
|
|
73d42c03d5 | ||
|
|
e96bedc1c8 | ||
|
|
c5f37fadba | ||
|
|
8052c5f973 | ||
|
|
c499c8a323 | ||
|
|
6b9962b2b3 | ||
|
|
0a81ae1ea0 | ||
|
|
c6d71ea902 | ||
|
|
4d850ebe6e | ||
|
|
dac18e876f | ||
|
|
d016876710 | ||
|
|
ddeb540df6 | ||
|
|
7733bc4419 | ||
|
|
128fe29619 | ||
|
|
23e200dece | ||
|
|
d9375c1dd1 | ||
|
|
aeebed6ef7 | ||
|
|
498ba257b6 | ||
|
|
6edba71c12 | ||
|
|
a559e7310a | ||
|
|
ebd172ab05 | ||
|
|
cdc3367d1b | ||
|
|
8d37d63a27 | ||
|
|
95f0f63276 | ||
|
|
5cab599a06 | ||
|
|
24715a85e5 | ||
|
|
559c03550d | ||
|
|
b8137d80cc | ||
|
|
0d7cac28c4 | ||
|
|
ae4fe73ac9 | ||
|
|
1c1397a5d8 | ||
|
|
cbebf9a94c | ||
|
|
119b3e7884 | ||
|
|
13607adf86 | ||
|
|
247c950cce | ||
|
|
1555d4abaf | ||
|
|
77a16a6074 | ||
|
|
28b1c9c6d6 | ||
|
|
1bb1734448 | ||
|
|
dd472bee64 | ||
|
|
216454f66f | ||
|
|
ca85854baf | ||
|
|
0682ed101d | ||
|
|
8a9a3cbf37 | ||
|
|
c74ccfaa8d | ||
|
|
f2fcd0f82f | ||
|
|
a43d439b31 | ||
|
|
b73ab97556 | ||
|
|
baca9a8ce5 | ||
|
|
bc64fdb1bc | ||
|
|
1d5d5e2499 | ||
|
|
8b29a50577 | ||
|
|
55a821f193 | ||
|
|
291180816a | ||
|
|
27695f5ae1 | ||
|
|
69d3bda01f | ||
|
|
1632530b21 | ||
|
|
c89f2fc627 | ||
|
|
d0c68dbc23 | ||
|
|
e41c36f534 | ||
|
|
9de962bbc9 | ||
|
|
4947169a7c | ||
|
|
f425a5866b | ||
|
|
3e30d4776a | ||
|
|
bca90c54e9 | ||
|
|
8c3f90fe36 | ||
|
|
0b316d6828 | ||
|
|
8772e51bd2 | ||
|
|
7e8afb4228 | ||
|
|
6659ab110c | ||
|
|
3b8c3647fa | ||
|
|
4fc8ac61f1 | ||
|
|
5b475f9206 | ||
|
|
c228f2fd68 | ||
|
|
395fbef19e | ||
|
|
a6155f9f83 | ||
|
|
a89d47b5c5 | ||
|
|
531d640d38 | ||
|
|
3505834014 | ||
|
|
cc0b981938 | ||
|
|
380b632dd0 | ||
|
|
fc038998d5 | ||
|
|
b8ef6dffb9 | ||
|
|
33fb979b2c | ||
|
|
b87f0bd5e8 | ||
|
|
69069afb0a | ||
|
|
9c79c80fd7 | ||
|
|
dcb5194252 | ||
|
|
4582ffb440 | ||
|
|
3ca7cae6e0 | ||
|
|
2a1619d71e | ||
|
|
893c7a7d2e | ||
|
|
274a201dba | ||
|
|
917f0d2b20 | ||
|
|
5a733c84be | ||
|
|
ffdd4d1ee9 | ||
|
|
2b60c71a4c | ||
|
|
6f59c80d86 | ||
|
|
d8861bbf48 | ||
|
|
63e920828b | ||
|
|
eeaee5fd13 | ||
|
|
fd6001090e | ||
|
|
968dcefc28 | ||
|
|
61cad18bcc | ||
|
|
78551cea61 | ||
|
|
c189b5e638 | ||
|
|
2c007e7303 | ||
|
|
610e34e05b | ||
|
|
bd83292a85 | ||
|
|
1a420476c5 | ||
|
|
038d327b50 | ||
|
|
cb5ae99e1d | ||
|
|
00d625ee33 | ||
|
|
f3f708ee9d | ||
|
|
4d094961b7 | ||
|
|
97b5abb47b | ||
|
|
3106058637 | ||
|
|
4068413f9f | ||
|
|
ccafbec485 | ||
|
|
6000dc251d | ||
|
|
b85b479396 | ||
|
|
5d892d14d5 | ||
|
|
da5209001b | ||
|
|
a6659601f4 | ||
|
|
bd834ba840 | ||
|
|
0ea07fbe01 | ||
|
|
8f72faf27d | ||
|
|
68c0b0e8a7 | ||
|
|
0078c0e601 | ||
|
|
1d4bd34dfc | ||
|
|
ff00043811 | ||
|
|
8ca6055935 | ||
|
|
390f2b35fc | ||
|
|
02fbce13f0 | ||
|
|
5d8562e072 | ||
|
|
ca439cf604 | ||
|
|
bdb0e24c40 | ||
|
|
fcc4f4eed8 | ||
|
|
ef27301238 | ||
|
|
d1e74b0da9 | ||
|
|
a1819e78e4 | ||
|
|
a455fc015b | ||
|
|
af2c10f2ab | ||
|
|
82ba39f99c | ||
|
|
471c9d5526 | ||
|
|
9df6de2673 | ||
|
|
1c10bde4b1 | ||
|
|
64eba585d9 | ||
|
|
6eb5c75ad4 | ||
|
|
23f0ee9e55 | ||
|
|
eec2fd00a2 | ||
|
|
749fc61885 | ||
|
|
df1c56da2d | ||
|
|
48b0df8e75 | ||
|
|
fb3655506f | ||
|
|
6929347da7 | ||
|
|
1dab570907 | ||
|
|
1719f24b57 | ||
|
|
2801431fab | ||
|
|
8c915d1687 | ||
|
|
7d8a62664a | ||
|
|
9d5b59e9bb | ||
|
|
f73d7111b4 | ||
|
|
42a044fd22 | ||
|
|
19ea85d9cc | ||
|
|
defec189e2 | ||
|
|
fbfff07dec | ||
|
|
1a836bd0fc | ||
|
|
35e3ac5841 | ||
|
|
19ce4c1cb7 | ||
|
|
36b016a37b | ||
|
|
e8b87b0db1 | ||
|
|
a09dd953ff | ||
|
|
73ed37f57a | ||
|
|
98a6c63ad6 | ||
|
|
1eb6e30369 | ||
|
|
68c1d9afaf | ||
|
|
42cd9a59b9 | ||
|
|
b7e1e54a92 | ||
|
|
78f62cc5e1 | ||
|
|
48834f96d3 | ||
|
|
1d69da1ca5 | ||
|
|
4c17d7d160 | ||
|
|
7ee685ca18 | ||
|
|
8ddc7220f7 | ||
|
|
2704c3f3de | ||
|
|
65c695e830 | ||
|
|
a1c09057c1 | ||
|
|
b6d60773e3 | ||
|
|
8636a15f4b | ||
|
|
96782bfa8e | ||
|
|
97d2af048c | ||
|
|
049ebdd542 | ||
|
|
bf3888585a | ||
|
|
35969e9f26 | ||
|
|
9cb5df31d1 | ||
|
|
cf03cb4ca4 | ||
|
|
63f4ef97fb | ||
|
|
8e0abec876 | ||
|
|
5ca3d01ea1 | ||
|
|
dbc08ba80f | ||
|
|
47e3279302 | ||
|
|
06f25c3950 | ||
|
|
e96fc32cc1 | ||
|
|
444b7d5aae | ||
|
|
01404ba581 | ||
|
|
0dc7f4e07e | ||
|
|
730c26f1e2 | ||
|
|
e30d1a40bc | ||
|
|
4e7f32aa88 | ||
|
|
44a3f651c2 | ||
|
|
8a42a53522 | ||
|
|
25f7c14f97 | ||
|
|
568338ad68 | ||
|
|
30dd9c5222 | ||
|
|
68367b002e | ||
|
|
cd1825d97a | ||
|
|
c421059e97 | ||
|
|
58a6f437c4 | ||
|
|
e032736c27 | ||
|
|
eb0d499ddf | ||
|
|
54ab57d8f6 | ||
|
|
eeb71982c8 | ||
|
|
ee8f071025 | ||
|
|
b6bc8f2a25 | ||
|
|
8352c9c6fd | ||
|
|
179b23ed6a | ||
|
|
353de39d4d | ||
|
|
d97be7043a | ||
|
|
517c8f0d24 | ||
|
|
2ce676885f | ||
|
|
cf0a42c6eb | ||
|
|
0214cfa299 | ||
|
|
81fff2b5e8 | ||
|
|
e5612a7373 | ||
|
|
969106e2b6 | ||
|
|
6bad9ac629 | ||
|
|
c1187dd457 | ||
|
|
e8ffcbae69 | ||
|
|
c2b6b40554 | ||
|
|
541a372f01 | ||
|
|
64cef9bb7d | ||
|
|
70be668c1a | ||
|
|
3ac8bf363a | ||
|
|
9e66231218 | ||
|
|
e55cf2bdf9 | ||
|
|
0a5263be35 | ||
|
|
5dd1fa0f98 | ||
|
|
82b2f920c1 | ||
|
|
1c0e1237c2 | ||
|
|
ceeed73dea | ||
|
|
890583a13a | ||
|
|
21c6730dc7 | ||
|
|
19727a648d | ||
|
|
b90aef4e1d | ||
|
|
412ffe4b46 | ||
|
|
c5cfe7e2e9 | ||
|
|
45356ae1fc | ||
|
|
86b0e95458 | ||
|
|
90fb619dfc | ||
|
|
5e89aa2726 | ||
|
|
82dad3217b | ||
|
|
47cb228e30 | ||
|
|
35c0b94e0d | ||
|
|
a7015f2517 | ||
|
|
4f471f39da | ||
|
|
f14641396f | ||
|
|
d97bbdf140 | ||
|
|
f1c42a698d | ||
|
|
8fb62628d2 | ||
|
|
5026bfa6c1 | ||
|
|
b37a92aaf7 | ||
|
|
c44e2a9526 | ||
|
|
c0ccc4a5c5 | ||
|
|
364dadc93f | ||
|
|
b45bdb52b2 | ||
|
|
7c612d8bcf | ||
|
|
3311bf6ac0 | ||
|
|
b5c160732e | ||
|
|
f8d00c0e7f | ||
|
|
42acae3ae3 | ||
|
|
571439871b | ||
|
|
e051e26dad | ||
|
|
4ddd3811b2 | ||
|
|
da54557aab | ||
|
|
52763ceaf7 | ||
|
|
c0ccbaebaf | ||
|
|
36953eef1a | ||
|
|
84c8a6eced | ||
|
|
1f023eebeb | ||
|
|
6af783ea91 | ||
|
|
cea0c7277c | ||
|
|
8e860ec5a6 | ||
|
|
7b93c0bb09 | ||
|
|
3ae8da7d22 | ||
|
|
c31eb2df42 | ||
|
|
d657f5df49 | ||
|
|
e89378453a | ||
|
|
3a57b436a4 | ||
|
|
657ce4fa0a | ||
|
|
dde7063da0 | ||
|
|
b3c4ff4dc0 | ||
|
|
b7d1488aa3 | ||
|
|
d586f82da1 | ||
|
|
a658493ac5 | ||
|
|
eaaeef2335 | ||
|
|
bef9bbaa6a | ||
|
|
32810f2ecd | ||
|
|
8856a635ed | ||
|
|
d6bd4ac7fd | ||
|
|
efa5fb609c | ||
|
|
2f920ba651 | ||
|
|
ed164ce69b | ||
|
|
974bbd5ff4 | ||
|
|
e1652d17d9 | ||
|
|
33656f8eb4 | ||
|
|
bbd561a772 | ||
|
|
2790111405 | ||
|
|
47b791e938 | ||
|
|
47b432e307 | ||
|
|
ce341a05e1 | ||
|
|
b992c876e9 | ||
|
|
9a750bad93 | ||
|
|
d127177029 | ||
|
|
724b8990be | ||
|
|
9b7506ee8d | ||
|
|
176d48707f | ||
|
|
452dcb5eec | ||
|
|
ae3de34033 | ||
|
|
45fc55dee9 | ||
|
|
c3a4dbb871 | ||
|
|
9a695a76ed | ||
|
|
36bd82ceb4 | ||
|
|
067b76ebd8 | ||
|
|
cb02b07395 | ||
|
|
81d718570d | ||
|
|
ee1b9e861e | ||
|
|
3905ba4ce2 | ||
|
|
0f9b50de50 | ||
|
|
271b83de2e | ||
|
|
7ef07385c6 | ||
|
|
aaca901fd9 | ||
|
|
ccaac2a5c7 | ||
|
|
147beb3963 | ||
|
|
e481f1cc99 | ||
|
|
5cec969ee3 | ||
|
|
12ac01a9cb | ||
|
|
c1ed5a5b33 | ||
|
|
4d8f471eca | ||
|
|
0dcb3e94ce | ||
|
|
3233c78130 | ||
|
|
dfb1a79179 | ||
|
|
9758b2722e | ||
|
|
5993b9855e | ||
|
|
6abca96da1 | ||
|
|
6972227b8d | ||
|
|
1c13ab6de5 | ||
|
|
7e403c65c2 | ||
|
|
08c6ebe10c | ||
|
|
73b913065f | ||
|
|
8db3f2b277 | ||
|
|
408de63ea3 | ||
|
|
6d3baaae47 | ||
|
|
30e1c7d54e | ||
|
|
28d3402793 | ||
|
|
e9daaa2eb8 | ||
|
|
e66f9597a9 | ||
|
|
dc7e62245d | ||
|
|
da953f0bc0 | ||
|
|
4b086ab2bc | ||
|
|
cd9bc1d8d7 | ||
|
|
cdaf63afa0 | ||
|
|
7ebc185b3a | ||
|
|
3222583a69 | ||
|
|
85fe0130c3 | ||
|
|
f7e37924e5 | ||
|
|
1afecf23aa | ||
|
|
68b26d5f41 | ||
|
|
4926c826af | ||
|
|
a27fa8b317 | ||
|
|
6b724d9572 | ||
|
|
2789ecc22a | ||
|
|
2eba317797 | ||
|
|
5856e3cc03 | ||
|
|
8cd59c39ed | ||
|
|
18efa4ff2c | ||
|
|
722e95abf2 | ||
|
|
04b7d8e1e2 | ||
|
|
b33e469501 | ||
|
|
cc469b116d | ||
|
|
9fe49b5546 | ||
|
|
0c89b7cdb1 | ||
|
|
90d48c1d30 | ||
|
|
2792c22ec9 | ||
|
|
745cf1c79d | ||
|
|
a838dac01b | ||
|
|
d5bbc7b1aa | ||
|
|
e1e6816544 | ||
|
|
64c0273554 | ||
|
|
532caea169 | ||
|
|
0c8d8d92ba | ||
|
|
af428ab0ae | ||
|
|
85b3605c33 | ||
|
|
f1431b7b77 | ||
|
|
1ea1d53971 | ||
|
|
8bf01858bb | ||
|
|
f05f527336 | ||
|
|
fa4c7a1eb7 | ||
|
|
3e6b3bcdc4 | ||
|
|
aca242046e | ||
|
|
be27ce4914 | ||
|
|
e8d49fae13 | ||
|
|
190b77ff95 | ||
|
|
6e78745ed5 | ||
|
|
f03def32fd | ||
|
|
a98ae69a03 | ||
|
|
43fe2390c8 | ||
|
|
d54e152a3d | ||
|
|
ac23c7bb4a | ||
|
|
66444e27b1 | ||
|
|
92baf75ccd | ||
|
|
0714dc34c5 | ||
|
|
aa068c70c2 | ||
|
|
70974efc74 | ||
|
|
acccba6ed4 | ||
|
|
2e549b164f | ||
|
|
3df2b80427 | ||
|
|
0ec89e8bbe | ||
|
|
694497803b | ||
|
|
b73ce14560 | ||
|
|
88db456127 | ||
|
|
6832b4a304 | ||
|
|
5079582e1f | ||
|
|
4313c45870 | ||
|
|
1f9e7f2ae8 | ||
|
|
f7bba745ab | ||
|
|
391ba77da9 | ||
|
|
1d7b43ffbc | ||
|
|
7256759488 | ||
|
|
f11c782c0f | ||
|
|
26aec7d129 | ||
|
|
d61c799846 | ||
|
|
c3c41c5b7d | ||
|
|
eeb76b1e50 | ||
|
|
caf462e9b8 | ||
|
|
4d70d3b909 | ||
|
|
6a1115ddda | ||
|
|
d3ae53e3ef | ||
|
|
4774cc4859 | ||
|
|
bc07dad4ae | ||
|
|
0f9ad0907e | ||
|
|
300ad15f5a | ||
|
|
ad786ab95f | ||
|
|
fe898315c3 | ||
|
|
96540af2b1 | ||
|
|
6889440014 | ||
|
|
e59d106315 | ||
|
|
7391a4086a | ||
|
|
b91f1959b4 | ||
|
|
0711fa700b | ||
|
|
a4dd5fccff | ||
|
|
4fad2ab619 | ||
|
|
91e81823a5 | ||
|
|
d0ab0bccb9 | ||
|
|
b2b91bfa57 | ||
|
|
fc857aad08 | ||
|
|
5874922367 | ||
|
|
1657f06a48 | ||
|
|
2ad9c3cc72 | ||
|
|
fae76f6d4e | ||
|
|
d0878aa805 | ||
|
|
020454e701 | ||
|
|
eedb83e863 | ||
|
|
8a6809848e | ||
|
|
3b2083134e | ||
|
|
b5fc074e35 | ||
|
|
bc794816db | ||
|
|
f1b5ac27a9 | ||
|
|
ea438d3626 | ||
|
|
6d93501dc7 | ||
|
|
09d0a9e3f8 | ||
|
|
2fef90e7eb | ||
|
|
c851f60de4 | ||
|
|
6b4bca50ee | ||
|
|
f05e37590a | ||
|
|
fbf06a4de0 | ||
|
|
25014a81c3 | ||
|
|
ce3e30ea02 | ||
|
|
1d026ab085 | ||
|
|
5d77f7e5b1 | ||
|
|
131e4f2446 | ||
|
|
8ab264af80 | ||
|
|
60d629a0c6 | ||
|
|
d337dbfa5d | ||
|
|
e735b9f90e | ||
|
|
95fb3dfcd2 | ||
|
|
582ec187f8 | ||
|
|
40ca804d93 | ||
|
|
b32e0f458c | ||
|
|
484a50949a | ||
|
|
a118f34b49 | ||
|
|
2818666a1a | ||
|
|
9143639357 | ||
|
|
f18d2ea629 | ||
|
|
938890c04c | ||
|
|
9173c73eca | ||
|
|
69c8a89dd2 | ||
|
|
b462ac019a | ||
|
|
3011d24905 | ||
|
|
120d3005ea | ||
|
|
2272977d67 | ||
|
|
cbe8587db3 | ||
|
|
6a4d505033 | ||
|
|
bd44f49175 | ||
|
|
acdcf82c6c | ||
|
|
afb09919ed | ||
|
|
d685888720 | ||
|
|
bda2468a86 | ||
|
|
2dea2d9d27 | ||
|
|
107d607d37 | ||
|
|
2c6513ac85 | ||
|
|
8ae1148ef9 | ||
|
|
5bd4be1950 | ||
|
|
dad88cb42e | ||
|
|
b6e01077ed | ||
|
|
538a05b359 | ||
|
|
1b3281457e | ||
|
|
c9ec5234d3 | ||
|
|
76b931108e | ||
|
|
84dc3c8fd9 | ||
|
|
2cddc49463 | ||
|
|
91b5a0afdd | ||
|
|
dfdc9c9fa5 | ||
|
|
aafbf6bc15 | ||
|
|
2e717882f1 | ||
|
|
14b53a4d5e | ||
|
|
04b321caae | ||
|
|
cad1851e95 | ||
|
|
012ead65b5 | ||
|
|
d549fcb2ae | ||
|
|
4c85e55176 | ||
|
|
1eb593703f | ||
|
|
771fc1788c | ||
|
|
ae9886080e | ||
|
|
d76baa3266 | ||
|
|
37b20571d2 | ||
|
|
4661fb26dc | ||
|
|
adffdb31f3 | ||
|
|
b9559d99da | ||
|
|
aa4a3ef940 | ||
|
|
3a2e1b5c94 | ||
|
|
44c35e6aee | ||
|
|
a56dc25fae | ||
|
|
4eeef41ed4 | ||
|
|
b77f85b697 | ||
|
|
9cd207595f | ||
|
|
c21e0739f2 | ||
|
|
83367dd519 | ||
|
|
0d9695de1d | ||
|
|
468e61e1e0 | ||
|
|
481e9b0d32 | ||
|
|
ce85a1b1d5 | ||
|
|
da74d0d732 | ||
|
|
e6306e5109 | ||
|
|
5fae9526d6 | ||
|
|
37f52cafc9 | ||
|
|
2a632512b3 | ||
|
|
079cff0bc0 | ||
|
|
7954ad0edf | ||
|
|
2500d192e8 | ||
|
|
480a72b6e2 | ||
|
|
b2c3dc1504 | ||
|
|
e170011e3c | ||
|
|
f3f611848c | ||
|
|
c3ce0eb794 | ||
|
|
1643287775 | ||
|
|
9e35229ebd | ||
|
|
046bd59726 | ||
|
|
e8027d3316 | ||
|
|
ad34ebff89 | ||
|
|
0ead390ef4 | ||
|
|
aefa73a06f | ||
|
|
f733497f0f | ||
|
|
ed917fa194 | ||
|
|
313df74202 | ||
|
|
db7c234053 | ||
|
|
91c12ca34f | ||
|
|
9f66e8e5d1 | ||
|
|
b5be938480 | ||
|
|
36583d1171 | ||
|
|
05e13ad05f | ||
|
|
475ce08d3e | ||
|
|
6962e15b6d | ||
|
|
7b72906096 | ||
|
|
9d43bb4252 | ||
|
|
7dd24bb79b | ||
|
|
82e402c271 | ||
|
|
827ce6c42a | ||
|
|
94a98a1866 | ||
|
|
0e585cd585 | ||
|
|
cd505ecced | ||
|
|
c8360b1994 | ||
|
|
a12baf684c | ||
|
|
910352280c | ||
|
|
dec854a012 | ||
|
|
03d4e97ad7 | ||
|
|
e061ba8123 | ||
|
|
23104b28b6 | ||
|
|
b497de0dae | ||
|
|
284fc2acbc | ||
|
|
cc8347a871 | ||
|
|
eb425dc4f2 | ||
|
|
4b7e93ab84 | ||
|
|
6f99209a62 | ||
|
|
a0cd94cfae | ||
|
|
2030f987db | ||
|
|
94e87f8a7d | ||
|
|
9a272f69c7 | ||
|
|
fc1f2b2a9f | ||
|
|
89fbe28ed1 | ||
|
|
216d101e56 | ||
|
|
e57262136c | ||
|
|
0b9bef066b | ||
|
|
4111cee3d6 | ||
|
|
0ef5a37e33 | ||
|
|
8b5a36a49f | ||
|
|
c6d1f80af2 | ||
|
|
b73b40b23c | ||
|
|
ccf91a129c | ||
|
|
1f3f6ce1e9 | ||
|
|
8f2e3d5fe4 | ||
|
|
b581752bd5 | ||
|
|
47481986a1 | ||
|
|
9af0e6ca44 | ||
|
|
9c419ef114 | ||
|
|
9b5a321a62 | ||
|
|
738cf6407c | ||
|
|
1d21ee7089 | ||
|
|
01b361fd3c | ||
|
|
4d46460f90 | ||
|
|
e9942e5527 | ||
|
|
8aa0e96377 | ||
|
|
a12fce1c1f | ||
|
|
e9d50eb10d | ||
|
|
8c618f95f7 | ||
|
|
d309628e1d | ||
|
|
f3f1dbc2d1 | ||
|
|
664f73b8a5 | ||
|
|
94f2681223 | ||
|
|
a182ca3ab7 | ||
|
|
be865af1fc | ||
|
|
c6ad8ee110 | ||
|
|
b814a8821c | ||
|
|
4d90d36225 | ||
|
|
fd673b39a4 | ||
|
|
1758b34eed | ||
|
|
16bd5e2ebc | ||
|
|
c892411484 | ||
|
|
9dead29ac3 | ||
|
|
96ce475206 | ||
|
|
788dc9b3f8 | ||
|
|
80af0bb148 | ||
|
|
fcb8b15ef2 | ||
|
|
9e190d9810 | ||
|
|
85a47e36b5 | ||
|
|
0dac87f2bc | ||
|
|
4852882c28 | ||
|
|
f3c5aed5d0 | ||
|
|
e4b17bdbcf | ||
|
|
c0eb20d31d | ||
|
|
f23d29deb7 | ||
|
|
28b0a8f7d7 | ||
|
|
cdd268afbc | ||
|
|
b2b224e5a7 | ||
|
|
f9c964b65e | ||
|
|
4b9d6fc794 | ||
|
|
c3c6e533e3 | ||
|
|
af30df58dc | ||
|
|
3587362c4a | ||
|
|
06a30316c2 | ||
|
|
8161d3ae09 | ||
|
|
ea470068bb | ||
|
|
e3378181ee | ||
|
|
9162f0e1fd | ||
|
|
69556f19ac | ||
|
|
ab3b9cba45 | ||
|
|
4b4f78b4cc | ||
|
|
0c48f76911 | ||
|
|
3cf4a3facc | ||
|
|
41d34de9e1 | ||
|
|
dfdebc35c8 | ||
|
|
bd2745d1fe | ||
|
|
64f2d874fe | ||
|
|
58668c11f3 | ||
|
|
bab1a417df | ||
|
|
b16718bfe4 | ||
|
|
8f58bb4f2c | ||
|
|
9cdb25344b | ||
|
|
96ce631784 | ||
|
|
bd5f19cbd0 | ||
|
|
cf1fa99399 | ||
|
|
4c3025ab24 | ||
|
|
01d463b4aa | ||
|
|
58001f367a | ||
|
|
517210eeb5 | ||
|
|
22034c22c6 | ||
|
|
33a67bc61c | ||
|
|
b0e89ed563 | ||
|
|
5eb08d9c4e | ||
|
|
59ec8c5c78 | ||
|
|
9fae26765a | ||
|
|
2ea81c0114 | ||
|
|
a4cef16ef2 | ||
|
|
1325e46192 | ||
|
|
071ecca875 | ||
|
|
d91e6e381e | ||
|
|
b54bf2bba4 | ||
|
|
32b8a2c243 | ||
|
|
bb055a3c84 | ||
|
|
57a89b733e | ||
|
|
50ee0ad3fd | ||
|
|
b7c55b4700 | ||
|
|
9f52da90c3 | ||
|
|
fac6e8a20e | ||
|
|
38ebf9c3b4 | ||
|
|
6240323704 | ||
|
|
d666564112 | ||
|
|
f4d4559cd4 | ||
|
|
e9c3b0567b | ||
|
|
56cf7064f5 | ||
|
|
a905f49721 | ||
|
|
9328065954 | ||
|
|
c7a8d977ec | ||
|
|
11db7590eb | ||
|
|
e1d1ecbc24 | ||
|
|
eb556fab10 | ||
|
|
bf60fc6d82 | ||
|
|
53761bf802 | ||
|
|
3bf4ac6ea1 | ||
|
|
45f12de546 | ||
|
|
4a46cf2ab7 | ||
|
|
30725af367 | ||
|
|
ece324a76f | ||
|
|
2ea69a84b2 | ||
|
|
f2f0d292e0 | ||
|
|
fc0fad29d0 | ||
|
|
9a954ab430 | ||
|
|
90caaaa14a | ||
|
|
98360ed9e8 | ||
|
|
f64a74e7b9 | ||
|
|
aae78055c8 | ||
|
|
6b25bf6c4f | ||
|
|
125e6238d1 | ||
|
|
504f75a1cf | ||
|
|
fa17ce5d40 | ||
|
|
14f39b8028 | ||
|
|
7e9a5c4a8f | ||
|
|
b8043a9755 | ||
|
|
da517f2d35 | ||
|
|
62aa6569f2 | ||
|
|
42e97f8be1 | ||
|
|
9a407ab714 | ||
|
|
750e00c981 | ||
|
|
d2847e9507 | ||
|
|
8a5afefc1c | ||
|
|
3dd65db651 | ||
|
|
1e1f551383 | ||
|
|
4258a840ac | ||
|
|
bca98f91e4 | ||
|
|
a79d2cf899 | ||
|
|
6a699d7f09 | ||
|
|
ba2729fa4a | ||
|
|
dba7a9d424 | ||
|
|
dc77c6b655 | ||
|
|
ed87814f50 | ||
|
|
d8faff47a8 | ||
|
|
ecb757bcaf | ||
|
|
73a6f0a347 | ||
|
|
db689d151e | ||
|
|
6bdd25b5d1 | ||
|
|
a7ec9d7d1f | ||
|
|
7f9911f164 | ||
|
|
0e07617877 | ||
|
|
c78cb89943 | ||
|
|
42b8c3669f | ||
|
|
6c0a8afba2 | ||
|
|
19bac6bd10 | ||
|
|
1f5fb43454 | ||
|
|
cd3f1d5ded | ||
|
|
47c983ed88 | ||
|
|
c39c58198d | ||
|
|
76c9f11922 | ||
|
|
a25f069f8e | ||
|
|
d87bc5fa1b | ||
|
|
5a482298e8 | ||
|
|
34d874f56d | ||
|
|
762681a421 | ||
|
|
b73ab37c94 | ||
|
|
0039585848 | ||
|
|
df5ccb6e77 | ||
|
|
be5e7f1536 | ||
|
|
d68f53733d | ||
|
|
dba90726c1 | ||
|
|
84dcd8f89c | ||
|
|
3b0bded82c | ||
|
|
dd0ac64e28 | ||
|
|
aa56e2cdcf | ||
|
|
07ac43ec0e | ||
|
|
31661d5484 | ||
|
|
9c44cd343f | ||
|
|
2d22f575a0 | ||
|
|
87a264ae40 | ||
|
|
690090acb4 | ||
|
|
3c5be31222 | ||
|
|
a66b40d79e | ||
|
|
7e31c55e37 | ||
|
|
9e30f974ef | ||
|
|
5e6d079fea | ||
|
|
4cc841d629 | ||
|
|
1497e8ef0f | ||
|
|
4c392e3a31 | ||
|
|
145d6f831a | ||
|
|
8aa1f29865 | ||
|
|
62b730f5f0 | ||
|
|
f35095e053 | ||
|
|
9e3515619d | ||
|
|
de7fb393c9 | ||
|
|
fed320be36 | ||
|
|
1b30d023ef | ||
|
|
806a818cb3 | ||
|
|
4014fec195 | ||
|
|
cae0311db6 | ||
|
|
7c6dfef1c6 | ||
|
|
51440964a7 | ||
|
|
f7a819fd57 | ||
|
|
378b9f3f67 | ||
|
|
cb3a7a1da0 | ||
|
|
6f4b533fc7 | ||
|
|
dbdc656e3e | ||
|
|
797aa68bfa | ||
|
|
80c17e5dcf | ||
|
|
7083c4e111 | ||
|
|
e0e0f0a9b1 | ||
|
|
b57c5ec92a | ||
|
|
08eb2bceb1 | ||
|
|
f439d10128 | ||
|
|
b87022ef28 | ||
|
|
17d1c16d9c | ||
|
|
0e3675ce1f | ||
|
|
92cd4693f4 | ||
|
|
7905b9fbeb | ||
|
|
0b4318b32c | ||
|
|
0fd80bedf2 | ||
|
|
380f297af3 | ||
|
|
f8f0944816 | ||
|
|
a5f833759a | ||
|
|
7ab90c6b6f | ||
|
|
a5a0d51ca7 |
@@ -2,6 +2,7 @@
|
||||
{promesa.core/let clojure.core/let
|
||||
promesa.core/->> clojure.core/->>
|
||||
promesa.core/-> clojure.core/->
|
||||
promesa.exec.csp/go-loop clojure.core/loop
|
||||
rumext.v2/defc clojure.core/defn
|
||||
rumext.v2/fnc clojure.core/fn
|
||||
app.common.data/export clojure.core/def
|
||||
|
||||
13
.editorconfig
Normal file
13
.editorconfig
Normal file
@@ -0,0 +1,13 @@
|
||||
root = true
|
||||
|
||||
[*.{cljs,cljc,clj,js,css,scss,html,yml,yaml,json,mustache}]
|
||||
charset = utf-8
|
||||
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
|
||||
end_of_line = lf
|
||||
|
||||
insert_final_newline = true
|
||||
|
||||
trim_trailing_whitespace = true
|
||||
9
.vscode/settings.json
vendored
Normal file
9
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"files.exclude": {
|
||||
"**/.clj-kondo": true,
|
||||
"**/.cpcache": true,
|
||||
"**/.lsp": true,
|
||||
"**/.shadow-cljs": true,
|
||||
"**/node_modules": true
|
||||
}
|
||||
}
|
||||
282
CHANGES.md
282
CHANGES.md
@@ -1,4 +1,282 @@
|
||||
# CHANGELOG
|
||||
|
||||
## 1.19.0
|
||||
|
||||
### :boom: Breaking changes & Deprecations
|
||||
|
||||
### :sparkles: New features
|
||||
|
||||
- Default naming of text layers [Taiga #2836](https://tree.taiga.io/project/penpot/us/2836)
|
||||
- Create typography style from a selected text layer [Taiga #3041](https://tree.taiga.io/project/penpot/us/3041)
|
||||
- Board as ruler origin [Taiga #4833](https://tree.taiga.io/project/penpot/us/4833)
|
||||
- Access tokens support [Taiga #4460](https://tree.taiga.io/project/penpot/us/4460)
|
||||
- Show interactions setting at the view mode [Taiga #1330](https://tree.taiga.io/project/penpot/issue/1330)
|
||||
- Improve dashboard performance related to thumbnails; now the thumbnails are
|
||||
rendered as bitmap images.
|
||||
- Add the ability to disable google fonts provider with the `disable-google-fonts-provider` flag
|
||||
- Add the ability to disable dashboard templates section with the `disable-dashboard-templates-section` flag
|
||||
- Add the ability to use the registration whitelist with OICD [Github #3348](https://github.com/penpot/penpot/issues/3348)
|
||||
- Add support for local caching of google fonts (this avoids exposing the final user IP to
|
||||
goolge and reduces the amount of request sent to google)
|
||||
- Set smooth/instant autoscroll depending on distance [GitHub #3377](https://github.com/penpot/penpot/issues/3377)
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix files can be opened from multiple urls [Taiga #5310](https://tree.taiga.io/project/penpot/issue/5310)
|
||||
- Fix asset color item was created from the selected layer [Taiga #5180](https://tree.taiga.io/project/penpot/issue/5180)
|
||||
- Fix unpublish more than one library at the same time [Taiga #5532](https://tree.taiga.io/project/penpot/issue/5532)
|
||||
- Fix drag projects on dahsboard [Taiga #5531](https://tree.taiga.io/project/penpot/issue/5531)
|
||||
- Fix allow team name to be all blank [Taiga #5527](https://tree.taiga.io/project/penpot/issue/5527)
|
||||
- Fix search font visualitation [Taiga #5523](https://tree.taiga.io/project/penpot/issue/5523)
|
||||
- Fix create and account only with spaces [Taiga #5518](https://tree.taiga.io/project/penpot/issue/5518)
|
||||
- Fix context menu outside screen [Taiga #5524](https://tree.taiga.io/project/penpot/issue/5524)
|
||||
- Fix graphic item rename on assets pannel [Taiga #5556](https://tree.taiga.io/project/penpot/issue/5556)
|
||||
- Fix component and media name validation on assets panel [Taiga #5555](https://tree.taiga.io/project/penpot/issue/5555)
|
||||
- Fix problem with selection shortcuts [Taiga #5492](https://tree.taiga.io/project/penpot/issue/5492)
|
||||
- Fix issue with paths line to curve and concurrent editing [Taiga #5191](https://tree.taiga.io/project/penpot/issue/5191)
|
||||
- Fix problems with locked layers [Taiga #5139](https://tree.taiga.io/project/penpot/issue/5139)
|
||||
- Fix export from shared prototype [Taiga #5565](https://tree.taiga.io/project/penpot/issue/5565)
|
||||
- Fix email change: validation error displaying even after both fields are identical [Taiga #5514](https://tree.taiga.io/project/penpot/issue/5514)
|
||||
- Fix scroll on viewer comment list [Taiga #5563](https://tree.taiga.io/project/penpot/issue/5563)
|
||||
- Fix context menu z-index [Taiga #5561](https://tree.taiga.io/project/penpot/issue/5561)
|
||||
- Fix select all checkbox on shared link config [Taiga #5566](https://tree.taiga.io/project/penpot/issue/5566)
|
||||
- Fix validation on full name input on account creation [Taiga #5516](https://tree.taiga.io/project/penpot/issue/5516)
|
||||
- Fix validation on team name input [Taiga #5510](https://tree.taiga.io/project/penpot/issue/5510)
|
||||
- Fix incorrect uri generation issues on share-link modal [Taiga #5564](https://tree.taiga.io/project/penpot/issue/5564)
|
||||
- Fix cache issues with share-links [Taiga #5559](https://tree.taiga.io/project/penpot/issue/5559)
|
||||
- Makes height priority for the rows/columns grids [#2774](https://github.com/penpot/penpot/issues/2774)
|
||||
- Fix problem with comments mode not staying [#3363](https://github.com/penpot/penpot/issues/3363)
|
||||
- Fix problem with comments when user left the team [Taiga #5562](https://tree.taiga.io/project/penpot/issue/5562)
|
||||
- Fix problem with images patterns repeating [#3372](https://github.com/penpot/penpot/issues/3372)
|
||||
- Fix grid not being clipped in frames [#3365](https://github.com/penpot/penpot/issues/3365)
|
||||
- Fix cut/delete text layer when while creating text [Taiga #5602](https://tree.taiga.io/project/penpot/issue/5602)
|
||||
- Fix picking a gradient color in recent colors for a new color in the assets tab [Taiga #5601](https://tree.taiga.io/project/penpot/issue/5601)
|
||||
- Fix problem with importation process [Taiga #5597](https://tree.taiga.io/project/penpot/issue/5597)
|
||||
- Fix problem with HSV color picker [#3317](https://github.com/penpot/penpot/issues/3317)
|
||||
- Fix problem with slashes in layers names for exporter [#3276](https://github.com/penpot/penpot/issues/3276)
|
||||
- Fix incorrect modified data on moving files on dashboard [Taiga #5530](https://tree.taiga.io/project/penpot/issue/5530)
|
||||
- Fix focus handling on comments edition [Taiga #5560](https://tree.taiga.io/project/penpot/issue/5560)
|
||||
- Fix incorrect fullname use on registring user after OIDC authentication [Taiga #5517](https://tree.taiga.io/project/penpot/issue/5517)
|
||||
- Fix incorrect modified-at on project after import file [Taiga #5268](https://tree.taiga.io/project/penpot/issue/5268)
|
||||
- Fix incorrect message after sending invitation to already member [Taiga 5599](https://tree.taiga.io/project/penpot/issue/5599)
|
||||
- Fix text decoration on button [Taiga #5301](https://tree.taiga.io/project/penpot/issue/5301)
|
||||
- Fix menu order on design tab [Taiga #5195](https://tree.taiga.io/project/penpot/issue/5195)
|
||||
- Fix search bar width on layer tab [Taiga #5445](https://tree.taiga.io/project/penpot/issue/5445)
|
||||
- Fix border radius values with decimals [Taiga #5283](https://tree.taiga.io/project/penpot/issue/5283)
|
||||
- Fix shortcuts translations not homogenized [Taiga #5141](https://tree.taiga.io/project/penpot/issue/5141)
|
||||
- Fix overlay manual position in nested boards [Taiga #5135](https://tree.taiga.io/project/penpot/issue/5135)
|
||||
- Fix close overlay from a nested board [Taiga #5587](https://tree.taiga.io/project/penpot/issue/5587)
|
||||
- Fix overlay position when it has shadow or blur [Taiga #4752](https://tree.taiga.io/project/penpot/issue/4752)
|
||||
- Fix overlay position when there are elements fixed when scrolling [Taiga #4383](https://tree.taiga.io/project/penpot/issue/4383)
|
||||
- Fix problem when sliding color picker in selected-colors [#3150](https://github.com/penpot/penpot/issues/3150)
|
||||
- Fix error screen on upload image error [Taiga #5608](https://tree.taiga.io/project/penpot/issue/5608)
|
||||
- Fix bad frame-id for certain componentes [#3205](https://github.com/penpot/penpot/issues/3205)
|
||||
- Fix paste elements at bottom of frame [Taig #5253](https://tree.taiga.io/project/penpot/issue/5253)
|
||||
- Fix new-file button on project not redirecting to the new file [Taiga #5610](https://tree.taiga.io/project/penpot/issue/5610)
|
||||
- Fix retrieve user comments in dashboard [Taiga #5607](https://tree.taiga.io/project/penpot/issue/5607)
|
||||
- Locks shapes when moved inside a locked parent [Taiga #5252](https://tree.taiga.io/project/penpot/issue/5252)
|
||||
- Fix rotate several elements in bulk [Taiga #5165](https://tree.taiga.io/project/penpot/issue/5165)
|
||||
- Fix onboarding slides height [Taiga #5373](https://tree.taiga.io/project/penpot/issue/5373)
|
||||
- Fix create typography with section closed [Taiga #5574](https://tree.taiga.io/project/penpot/issue/5574)
|
||||
- Fix exports menu on viewer mode [Taiga #5568](https://tree.taiga.io/project/penpot/issue/5568)
|
||||
- Fix create empty comments [Taiga #5536](https://tree.taiga.io/project/penpot/issue/5536)
|
||||
- Fix position of text cursor is a bit too high in Invitations section [Taiga #5511](https://tree.taiga.io/project/penpot/issue/5511)
|
||||
- Fix undo when updating several texts [Taiga #5197](https://tree.taiga.io/project/penpot/issue/5197)
|
||||
- Fix assets right click button for multiple selection [Taiga #5545](https://tree.taiga.io/project/penpot/issue/5545)
|
||||
- Fix problem with precision in resizes [Taiga #5623](https://tree.taiga.io/project/penpot/issue/5623)
|
||||
- Fix absolute positioned layouts not showing flex properties [Taiga #5630](https://tree.taiga.io/project/penpot/issue/5630)
|
||||
- Fix text gradient handlers [Taiga #4047](https://tree.taiga.io/project/penpot/issue/4047)
|
||||
- Fix when user deletes one file during import it is impossible to finish importing of second file [Taiga #5656](https://tree.taiga.io/project/penpot/issue/5656)
|
||||
- Fix export multiple images when only one of them has export settings [Taiga #5649](https://tree.taiga.io/project/penpot/issue/5649)
|
||||
- Fix error when a user different than the thread creator edits a comment [Taiga #5647](https://tree.taiga.io/project/penpot/issue/5647)
|
||||
- Fix unnecessary button [Taiga #3312](https://tree.taiga.io/project/penpot/issue/3312)
|
||||
- Fix copy color information in several formats [Taiga #4723](https://tree.taiga.io/project/penpot/issue/4723)
|
||||
- Fix dropdown width [Taiga #5541](https://tree.taiga.io/project/penpot/issue/5541)
|
||||
- Fix enable comment mode and insert image keeps on comment mode [Taiga #5678](https://tree.taiga.io/project/penpot/issue/5678)
|
||||
- Fix enable undo just after using pencil [Taiga #5674](https://tree.taiga.io/project/penpot/issue/5674)
|
||||
- Fix 400 error when user changes password [Taiga #5643](https://tree.taiga.io/project/penpot/issue/5643)
|
||||
- Fix cannot undo layer styles [Taiga #5676](https://tree.taiga.io/project/penpot/issue/5676)
|
||||
- Fix unexpected exception on boolean shapes [Taiga #5685](https://tree.taiga.io/project/penpot/issue/5685)
|
||||
- Fix ctrl+z on select not working [Taiga #5677](https://tree.taiga.io/project/penpot/issue/5677)
|
||||
- Fix thubmnail rendering flashing [Taiga #5675](https://tree.taiga.io/project/penpot/issue/5675)
|
||||
|
||||
### :arrow_up: Deps updates
|
||||
|
||||
- Update google fonts catalog (at 2023/07/06) [Taiga #5592](https://tree.taiga.io/project/penpot/issue/5592)
|
||||
|
||||
|
||||
### :heart: Community contributions by (Thank you!)
|
||||
|
||||
- Update Typography palette order (by @akshay-gupta7) [Github #3156](https://github.com/penpot/penpot/pull/3156)
|
||||
- Palettes (color, typographies) empty state (by @akshay-gupta7) [Github #3160](https://github.com/penpot/penpot/pull/3160)
|
||||
- Duplicate objects via drag + alt (by @akshay-gupta7) [Github #3147](https://github.com/penpot/penpot/pull/3147)
|
||||
- Set line-height to auto as 1.2 (by @akshay-gupta7) [Github #3185](https://github.com/penpot/penpot/pull/3185)
|
||||
- Click to select full values at the design sidebar (by @akshay-gupta7) [Github #3179](https://github.com/penpot/penpot/pull/3179)
|
||||
- Fix rect filter bounds math (by @ryanbreen) [Github #3180](https://github.com/penpot/penpot/pull/3180)
|
||||
- Removed sizing variables from radius (by @ondrejkonec) [Github #3184](https://github.com/penpot/penpot/pull/3184)
|
||||
- Dashboard search, set focus after shortcut (by @akshay-gupta7) [Github #3196](https://github.com/penpot/penpot/pull/3196)
|
||||
- Library name dropdown arrow is overlapped by library name (by @ondrejkonec) [Taiga #5200](https://tree.taiga.io/project/penpot/issue/5200)
|
||||
- Reorder shadows (by @akshay-gupta7) [Github #3236](https://github.com/penpot/penpot/pull/3236)
|
||||
- Open project in new tab from workspace (by @akshay-gupta7) [Github #3246](https://github.com/penpot/penpot/pull/3246)
|
||||
- Distribute fix enabled when two elements were selected (by @dfelinto) [Github #3266](https://github.com/penpot/penpot/pull/3266)
|
||||
- Distribute vertical spacing failing for overlapped text (by @dfelinto) [Github #3267](https://github.com/penpot/penpot/pull/3267)
|
||||
- bug Change independent corner radius input tooltips #3332 (by @astudentinearth) [Github #3332](https://github.com/penpot/penpot/pull/3332)
|
||||
|
||||
## 1.18.6
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix comments navigation from workspace [Taiga #5504](https://tree.taiga.io/project/penpot/issue/5504)
|
||||
|
||||
### :sparkles: Enhancements
|
||||
|
||||
- Add the ability to overwrite internal resolver with `PENPOT_INTERNAL_RESOLVER` environment
|
||||
variable [GH #3310](https://github.com/penpot/penpot/issues/3310)
|
||||
|
||||
## 1.18.5
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix add flow option in contextual menu for frames
|
||||
- Fix issues related with invitations
|
||||
- Fix problem with undefined gaps
|
||||
- Add deleted fonts auto match mechanism
|
||||
|
||||
## 1.18.4
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix zooming while color picker breaks UI [GH #3214](https://github.com/penpot/penpot/issues/3214)
|
||||
- Fix problem with layout not reflowing on shape deletion [Taiga #5289](https://tree.taiga.io/project/penpot/issue/5289)
|
||||
- Fix extra long typography names on assets and palette [Taiga #5199](https://tree.taiga.io/project/penpot/issue/5199)
|
||||
- Fix background-color property on inspect code [Taiga #5300](https://tree.taiga.io/project/penpot/issue/5300)
|
||||
- Preview layer blend modes (by @akshay-gupta7) [Github #3235](https://github.com/penpot/penpot/pull/3235)
|
||||
|
||||
## 1.18.3
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix problem with rulers not placing correctly [Taiga #5093](https://tree.taiga.io/project/penpot/issue/5093)
|
||||
- Fix page context menu [Taiga #5145](https://tree.taiga.io/project/penpot/issue/5145)
|
||||
- Fix project file count [Taiga #5148](https://tree.taiga.io/project/penpot/issue/5148)
|
||||
- Fix OIDC roles checking mechanism [GH #3152](https://github.com/penpot/penpot/issues/3152)
|
||||
- Import updated translation strings from weblate
|
||||
|
||||
### :arrow_up: Deps updates
|
||||
|
||||
## 1.18.2
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix problem with frame title rotation
|
||||
- Fix first level board "Show in view mode" is automatically unchecked [Taiga #5136](https://tree.taiga.io/project/penpot/issue/5136)
|
||||
|
||||
## 1.18.1
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix problems with imported SVG shadows [Taiga #4922](https://tree.taiga.io/project/penpot/issue/4922)
|
||||
- Fix problems with imported SVG embedded images and transforms [Taiga #4639](https://tree.taiga.io/project/penpot/issue/4639)
|
||||
|
||||
## 1.18.0
|
||||
|
||||
### :sparkles: New features
|
||||
|
||||
- Adds more accessibility improvements in dashboard [Taiga #4577](https://tree.taiga.io/project/penpot/us/4577)
|
||||
- Adds paddings and gaps prediction on layout creation [Taiga #4838](https://tree.taiga.io/project/penpot/task/4838)
|
||||
- Add visual feedback when proportionally scaling text elements with **K** [Taiga #3415](https://tree.taiga.io/project/penpot/us/3415)
|
||||
- Add visualization and mouse control to paddings, margins and gaps in frames with layout [Taiga #4839](https://tree.taiga.io/project/penpot/task/4839)
|
||||
- Allow for absolute positioned elements inside layout [Taiga #4834](https://tree.taiga.io/project/penpot/us/4834)
|
||||
- Add z-index option for flex layout items [Taiga #2980](https://tree.taiga.io/project/penpot/us/2980)
|
||||
- Scale content proportionally affects strokes, shadows, blurs and corners [Taiga #1951](https://tree.taiga.io/project/penpot/us/1951)
|
||||
- Use tabulators to navigate layers [Taiga #5010](https://tree.taiga.io/project/penpot/issue/5010)
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix problem with rules position on changing pages [Taiga #4847](https://tree.taiga.io/project/penpot/issue/4847)
|
||||
- Fix error streen when uploading wrong SVG [#2995](https://github.com/penpot/penpot/issues/2995)
|
||||
- Fix selecting children from hidden parent layers [Taiga #4934](https://tree.taiga.io/project/penpot/issue/4934)
|
||||
- Fix problem when undoing multiple selected colors [Taiga #4920](https://tree.taiga.io/project/penpot/issue/4920)
|
||||
- Allow selection of empty board by partial rect [Taiga #4806](https://tree.taiga.io/project/penpot/issue/4806)
|
||||
- Improve behavior for undo on text edition [Taiga #4693](https://tree.taiga.io/project/penpot/issue/4693)
|
||||
- Improve deeps selection of nested arboards [Taiga #4913](https://tree.taiga.io/project/penpot/issue/4913)
|
||||
- Fix problem on selection numeric inputs on Firefox [#2991](https://github.com/penpot/penpot/issues/2991)
|
||||
- Changed the text dominant-baseline to use ideographic [Taiga #4791](https://tree.taiga.io/project/penpot/issue/4791)
|
||||
- Viewer wrong translations [Github #3035](https://github.com/penpot/penpot/issues/3035)
|
||||
- Fix problem with text editor in Safari
|
||||
- Fix unlink library color when blur color picker input [#3026](https://github.com/penpot/penpot/issues/3026)
|
||||
- Fix snap pixel when moving path points on high zoom [#2930](https://github.com/penpot/penpot/issues/2930)
|
||||
- Fix shortcuts for zoom now take into account the mouse position [#2924](https://github.com/penpot/penpot/issues/2924)
|
||||
- Fix close colorpicker on Firefox when mouse-up is outside the picker [#2911](https://github.com/penpot/penpot/issues/2911)
|
||||
- Fix problems with touch devices and Wacom tablets [#2216](https://github.com/penpot/penpot/issues/2216)
|
||||
- Fix problem with board titles misplaced [Taiga #4738](https://tree.taiga.io/project/penpot/issue/4738)
|
||||
- Fix problem with alt getting stuck when alt+tab [Taiga #5013](https://tree.taiga.io/project/penpot/issue/5013)
|
||||
- Fix problem with z positioning of elements [Taiga #5014](https://tree.taiga.io/project/penpot/issue/5014)
|
||||
- Fix problem in Firefox with scroll jumping when changin pages [#3052](https://github.com/penpot/penpot/issues/3052)
|
||||
- Fix nested frame interaction created flow in wrong frame [Taiga #5043](https://tree.taiga.io/project/penpot/issue/5043)
|
||||
- Font-Kerning does not work on Artboard Export to PNG/JPG/PDF [#3029](https://github.com/penpot/penpot/issues/3029)
|
||||
- Fix manipulate duplicated project (delete, duplicate, rename, pin/unpin...) [Taiga #5027](https://tree.taiga.io/project/penpot/issue/5027)
|
||||
- Fix deleted files appear in search results [Taiga #5002](https://tree.taiga.io/project/penpot/issue/5002)
|
||||
- Fix problem with selected colors and texts [Taiga #5051](https://tree.taiga.io/project/penpot/issue/5051)
|
||||
- Fix problem when assigning color from palette or assets [Taiga #5050](https://tree.taiga.io/project/penpot/issue/5050)
|
||||
- Fix shortcuts for alignment [Taiga #5030](https://tree.taiga.io/project/penpot/issue/5030)
|
||||
- Fix path options not showing when editing rects or ellipses [Taiga #5053](https://tree.taiga.io/project/penpot/issue/5053)
|
||||
- Fix tooltips for some alignment options are truncated on design tab [Taiga #5040](https://tree.taiga.io/project/penpot/issue/5040)
|
||||
- Fix horizontal margins drag don't always start from place [Taiga #5020](https://tree.taiga.io/project/penpot/issue/5020)
|
||||
- Fix multiplayer username sometimes is not displayed correctly [Taiga #4400](https://tree.taiga.io/project/penpot/issue/4400)
|
||||
- Show warning when trying to invite a user that is already in members [Taiga #4147](https://tree.taiga.io/project/penpot/issue/4147)
|
||||
- Fix problem with text out of borders when changing from auto-width to fixed [Taiga #4308](https://tree.taiga.io/project/penpot/issue/4308)
|
||||
- Fix header not showing when exiting fullscreen mode in viewer [Taiga #4244](https://tree.taiga.io/project/penpot/issue/4244)
|
||||
- Fix visual problem in select options [Taiga #5028](https://tree.taiga.io/project/penpot/issue/5028)
|
||||
- Forbid empty names for assets [Taiga #5056](https://tree.taiga.io/project/penpot/issue/5056)
|
||||
- Select children after ungroup action [Taiga #4917](https://tree.taiga.io/project/penpot/issue/4917)
|
||||
- Fix problem with guides not showing when moving over nested frames [Taiga #4905](https://tree.taiga.io/project/penpot/issue/4905)
|
||||
- Fix change email and password for users signed in via social login [Taiga #4273](https://tree.taiga.io/project/penpot/issue/4273)
|
||||
- Fix drag and drop files from browser or file explorer under circumstances [Taiga #5054](https://tree.taiga.io/project/penpot/issue/5054)
|
||||
- Fix problem when copy/pasting shapes [Taiga #4931](https://tree.taiga.io/project/penpot/issue/4931)
|
||||
- Fix problem with color picker not able to change hue [Taiga #5065](https://tree.taiga.io/project/penpot/issue/5065)
|
||||
- Fix problem with outer stroke in texts [Taiga #5078](https://tree.taiga.io/project/penpot/issue/5078)
|
||||
- Fix problem with text carring over next line when changing to fixed [Taiga #5067](https://tree.taiga.io/project/penpot/issue/5067)
|
||||
- Fix don't show invite user hero to users with editor role [Taiga #5086](https://tree.taiga.io/project/penpot/issue/5086)
|
||||
- Fix enter emails on onboarding new user creating team [Taiga #5089](https://tree.taiga.io/project/penpot/issue/5089)
|
||||
- Fix invalid files amount after moving on dashboard [Taiga #5080](https://tree.taiga.io/project/penpot/issue/5080)
|
||||
- Fix dashboard left sidebar, the [x] overlaps the field [Taiga #5064](https://tree.taiga.io/project/penpot/issue/5064)
|
||||
- Fix expanded typography on assets sidebar is moving [Taiga #5063](https://tree.taiga.io/project/penpot/issue/5063)
|
||||
- Fix spelling mistake in confirmation after importing only 1 file [Taiga #5095](https://tree.taiga.io/project/penpot/issue/5095)
|
||||
- Fix problem with selection colors and texts [Taiga #5079](https://tree.taiga.io/project/penpot/issue/5079)
|
||||
- Remove "show in view mode" flag when moving frame to frame [Taiga #5091](https://tree.taiga.io/project/penpot/issue/5091)
|
||||
- Fix problem creating files in project page [Taiga #5060](https://tree.taiga.io/project/penpot/issue/5060)
|
||||
- Disable empty names on rename files [Taiga #5088](https://tree.taiga.io/project/penpot/issue/5088)
|
||||
- Fix problem with SVG and flex layout [Taiga #](https://tree.taiga.io/project/penpot/issue/5099)
|
||||
- Fix unpublish and delete shared library warning messages [Taiga #5090](https://tree.taiga.io/project/penpot/issue/5090)
|
||||
- Fix last update project timer update after creating new file [Taiga #5096](https://tree.taiga.io/project/penpot/issue/5096)
|
||||
- Fix dashboard scrolling using 'Page Up' and 'Page Down' [Taiga #5081](https://tree.taiga.io/project/penpot/issue/5081)
|
||||
- Fix view mode header buttons overlapping in small resolutions [Taiga #5058](https://tree.taiga.io/project/penpot/issue/5058)
|
||||
- Fix precision for wrap in flex [Taiga #5072](https://tree.taiga.io/project/penpot/issue/5072)
|
||||
- Fix relative position overlay positioning [Taiga #5092](https://tree.taiga.io/project/penpot/issue/5092)
|
||||
- Fix hide grid keyboard shortcut [Github #3071](https://github.com/penpot/penpot/pull/3071)
|
||||
- Fix problem with opacity in imported SVG's [Taiga #4923](https://tree.taiga.io/project/penpot/issue/4923)
|
||||
|
||||
### :heart: Community contributions by (Thank you!)
|
||||
- To @ondrejkonec: for contributing to the code with:
|
||||
- Refactor CSS variables [Github #2948](https://github.com/penpot/penpot/pull/2948)
|
||||
|
||||
## 1.17.3
|
||||
|
||||
### :bug: Bugs fixed
|
||||
- Fix copy and paste very nested inside itself [Taiga #4848](https://tree.taiga.io/project/penpot/issue/4848)
|
||||
- Fix custom fonts not rendered correctly [Taiga #4874](https://tree.taiga.io/project/penpot/issue/4874)
|
||||
- Fix problem with shadows and blur on multiple selection
|
||||
- Fix problem with redo shortcut
|
||||
- Fix Component texts not displayed in assets panel [Taiga #4907](https://tree.taiga.io/project/penpot/issue/4907)
|
||||
- Fix search field has implemented shared styles for "close icon" and "search icon" [Taiga #4927](https://tree.taiga.io/project/penpot/issue/4927)
|
||||
- Fix Handling correctly slashes "/" in emails [Taiga #4906](https://tree.taiga.io/project/penpot/issue/4906)
|
||||
- Fix Change text color from selected colors [Taiga #4933](https://tree.taiga.io/project/penpot/issue/4933)
|
||||
|
||||
### :sparkles: Enhancements
|
||||
|
||||
- Adds environment variables for specifying the export and backend URI for the frontend docker image, thanks to @Supernova3339 for the initial PR and suggestion [Github #2984](https://github.com/penpot/penpot/issues/2984)
|
||||
|
||||
## 1.17.2
|
||||
|
||||
### :bug: Bugs fixed
|
||||
@@ -8,10 +286,6 @@
|
||||
- Fix correct behaviour for space-around and added space-evenly option
|
||||
- Fix duplicate with alt and undo only undo one step [Taiga #4746](https://tree.taiga.io/project/penpot/issue/4746)
|
||||
- Fix problem creating frames inside layout [Taiga #4844](https://tree.taiga.io/project/penpot/issue/4844)
|
||||
|
||||
## 1.17.2
|
||||
|
||||
### :bug: Bugs fixed
|
||||
- Fix paste board inside itself [Taiga #4775](https://tree.taiga.io/project/penpot/issue/4775)
|
||||
- Fix middle button panning can drag guides [Taiga #4266](https://tree.taiga.io/project/penpot/issue/4266)
|
||||
|
||||
|
||||
@@ -101,14 +101,14 @@ Each commit should have:
|
||||
|
||||
Examples of good commit messages:
|
||||
|
||||
- :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
|
||||
- `: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 ##
|
||||
|
||||
14
README.md
14
README.md
@@ -4,7 +4,7 @@
|
||||
|
||||
<h1 align="center">
|
||||
<br>
|
||||
<img src="https://penpot.app/images/readme/readme-logo.jpg" alt="PENPOT">
|
||||
<img src="https://penpot.app/images/readme/git-readme-header.png" alt="PENPOT">
|
||||
</h1>
|
||||
|
||||
<p align="center"><a href="https://www.mozilla.org/en-US/MPL/2.0" rel="nofollow"><img src="https://camo.githubusercontent.com/3fcf3d6b678ea15fde3cf7d6af0e242160366282d62a7c182d83a50bfee3f45e/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4d504c2d322e302d626c75652e737667" alt="License: MPL-2.0" data-canonical-src="https://img.shields.io/badge/MPL-2.0-blue.svg" style="max-width:100%;"></a>
|
||||
@@ -26,6 +26,8 @@
|
||||
|
||||

|
||||
|
||||
**:tada: [Important Notice!] :tada:** Our very first **Penpot Fest** is happening on June 28-30, Barcelona (Spain). **Secure yourself a ticket** to know everything about the present and future of Penpot and be part of the conversation! See details on the amazing venue and speakers lineup at [penpotfest.org](https://penpotfest.org)! :zap:
|
||||
|
||||
Penpot is the first **Open Source** design and prototyping platform meant for cross-domain teams. Non dependent on operating systems, Penpot is web based and works with open standards (SVG). Penpot invites designers all over the world to fall in love with open source while getting developers excited about the design process in return.
|
||||
|
||||
## Table of contents ##
|
||||
@@ -50,7 +52,7 @@ Being web based, Penpot is not dependent on operating systems or local installat
|
||||
Using SVG as no other design and prototyping tool does, Penpot files sport compatibility with most of the vectorial tools, are tech friendly and extremely easy to use on the web. We make sure you will always own your work.
|
||||
|
||||
<p align="center">
|
||||
<img src="https://penpot.app/images/readme/open-source.png" alt="Open Source">
|
||||
<img src="https://penpot.app/images/readme/git-open.png" alt="Open Source" style="width: 65%;">
|
||||
</p>
|
||||
|
||||
|
||||
@@ -74,7 +76,7 @@ Here’s a step-by-step guide on [getting started with Docker.](https://help.pen
|
||||
If you prefer not to install Penpot in a local environment, [login or register on our Penpot cloud app](https://design.penpot.app). Create a team to work together on projects and share design assets or jump right away into Penpot and **start designing** on your own.
|
||||
|
||||
<p align="center">
|
||||
<img src="https://help.penpot.app/img/home-techguide.png" alt="Getting started">
|
||||
<img src="https://penpot.app/images/readme/git-self-host.png" alt="Getting started" style="width: 65%;">
|
||||
</p>
|
||||
|
||||
## Community ##
|
||||
@@ -93,7 +95,7 @@ You will find the following categories:
|
||||
- [Penpot in your language](https://community.penpot.app/c/penpot-in-your-language/12)
|
||||
|
||||
<p align="center">
|
||||
<img src="https://penpot.app/images/readme/cross-teams.webp" alt="Community">
|
||||
<img src="https://penpot.app/images/readme/git-collaborate.png" alt="Communnity" style="width: 65%;">
|
||||
</p>
|
||||
|
||||
## Contributing ##
|
||||
@@ -111,7 +113,7 @@ Every sort of contribution will be very helpful to enhance Penpot. How you’ll
|
||||
To find (almost) everything you need to know on how to contribute to Penpot, refer to the [contributing-guide](https://help.penpot.app/contributing-guide/).
|
||||
|
||||
<p align="center">
|
||||
<img src="https://help.penpot.app/img/home-contributing.png" alt="Contributing">
|
||||
<img src="https://penpot.app/images/readme/git-community.png" alt="Contributing" style="width: 65%;">
|
||||
</p>
|
||||
|
||||
## Resources ##
|
||||
@@ -124,7 +126,7 @@ You can ask and answer questions, have open-ended conversations, and follow alon
|
||||
|
||||
✏️ [Tutorials](https://www.youtube.com/playlist?list=PLgcCPfOv5v54WpXhHmNO7T-YC7AE-SRsr)
|
||||
|
||||
🏘️ [Architecture](https://help.penpot.app/technical-guide/architecture/)
|
||||
🏘️ [Architecture](https://help.penpot.app/technical-guide/developer/architecture/)
|
||||
|
||||
📚 [Dev Diaries](https://penpot.app/dev-diaries.html)
|
||||
|
||||
|
||||
11
THANKYOU.md
11
THANKYOU.md
@@ -5,24 +5,25 @@ We want to thank to the amazing people that help us! Thank you! You're the best!
|
||||
## Security
|
||||
* Husnain Iqbal (CEO OF ALPHA INFERNO PVT LTD)
|
||||
* [Shiraz Ali Khan](https://www.linkedin.com/in/shiraz-ali-khan-1ba508180/)
|
||||
* Vaibhav Shukla
|
||||
|
||||
## Internationalization
|
||||
* [00ff88](https://hosted.weblate.org/user/00ff88)
|
||||
* [AhmadHB](https://hosted.weblate.org/user/AhmadHB)
|
||||
* [Aimee](https://hosted.weblate.org/user/Aimee)
|
||||
* [alejandro.alonso](alejandro.https://hosted.weblate.org/user/alonso)
|
||||
* [alejandro.alonso](https://hosted.weblate.org/user/alejandro.alonso)
|
||||
* [alexpawlak](https://hosted.weblate.org/user/alexpawlak)
|
||||
* [allytiago](https://hosted.weblate.org/user/allytiago)
|
||||
* [alonso.torres](alonso.https://hosted.weblate.org/user/torres)
|
||||
* [andres.moya](andres.https://hosted.weblate.org/user/moya)
|
||||
* [alonso.torres](https://hosted.weblate.org/user/alonso.torres)
|
||||
* [andres.moya](https://hosted.weblate.org/user/andres.moya)
|
||||
* [antoniofsm](https://hosted.weblate.org/user/antoniofsm)
|
||||
* [ascarida](https://hosted.weblate.org/user/ascarida)
|
||||
* [Bechii](https://hosted.weblate.org/user/Bechii)
|
||||
* [Beeby](https://hosted.weblate.org/user/Beeby)
|
||||
* [bingling-sama](bingling-https://hosted.weblate.org/user/sama)
|
||||
* [bingling-sama](https://hosted.weblate.org/user/bingling-sama)
|
||||
* [devadarta](https://hosted.weblate.org/user/devadarta)
|
||||
* [diacritica](https://hosted.weblate.org/user/diacritica)
|
||||
* [dundzys.vincas](dundzys.https://hosted.weblate.org/user/vincas)
|
||||
* [dundzys.vincas](https://hosted.weblate.org/user/dundzys.vincas)
|
||||
* [Eranot](https://hosted.weblate.org/user/Eranot)
|
||||
* [erral](https://hosted.weblate.org/user/erral)
|
||||
* [ersen](https://hosted.weblate.org/user/ersen)
|
||||
|
||||
@@ -16,16 +16,11 @@
|
||||
{: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.*"]
|
||||
:exclude [#".*Log4j2Plugins\.dat$"]
|
||||
:basis basis}))
|
||||
|
||||
(defn compile [_]
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
{:deps
|
||||
{:mvn/repos
|
||||
{"sonatype" {:url "https://oss.sonatype.org/content/repositories/snapshots/"}}
|
||||
|
||||
:deps
|
||||
{penpot/common {:local/root "../common"}
|
||||
org.clojure/clojure {:mvn/version "1.11.1"}
|
||||
org.clojure/core.async {:mvn/version "1.6.673"}
|
||||
|
||||
;; Logging
|
||||
org.zeromq/jeromq {:mvn/version "0.5.3"}
|
||||
|
||||
com.github.luben/zstd-jni {:mvn/version "1.5.2-5"}
|
||||
org.clojure/data.fressian {:mvn/version "1.0.0"}
|
||||
com.github.luben/zstd-jni {:mvn/version "1.5.5-4"}
|
||||
|
||||
io.prometheus/simpleclient {:mvn/version "0.16.0"}
|
||||
io.prometheus/simpleclient_hotspot {:mvn/version "0.16.0"}
|
||||
@@ -18,28 +17,30 @@
|
||||
|
||||
io.prometheus/simpleclient_httpserver {:mvn/version "0.16.0"}
|
||||
|
||||
io.lettuce/lettuce-core {:mvn/version "6.2.2.RELEASE"}
|
||||
io.lettuce/lettuce-core {:mvn/version "6.2.4.RELEASE"}
|
||||
java-http-clj/java-http-clj {:mvn/version "0.4.3"}
|
||||
|
||||
funcool/yetti
|
||||
{:git/tag "v9.12"
|
||||
:git/sha "51646d8"
|
||||
{:git/tag "v9.15"
|
||||
:git/sha "aa9b967"
|
||||
:git/url "https://github.com/funcool/yetti.git"
|
||||
:exclusions [org.slf4j/slf4j-api]}
|
||||
|
||||
com.github.seancorfield/next.jdbc {:mvn/version "1.3.847"}
|
||||
metosin/reitit-core {:mvn/version "0.5.18"}
|
||||
org.postgresql/postgresql {:mvn/version "42.5.1"}
|
||||
com.github.seancorfield/next.jdbc {:mvn/version "1.3.883"}
|
||||
metosin/reitit-core {:mvn/version "0.6.0"}
|
||||
|
||||
org.postgresql/postgresql {:mvn/version "42.6.0"}
|
||||
|
||||
com.zaxxer/HikariCP {:mvn/version "5.0.1"}
|
||||
|
||||
io.whitfin/siphash {:mvn/version "2.0.0"}
|
||||
|
||||
buddy/buddy-hashers {:mvn/version "1.8.158"}
|
||||
buddy/buddy-sign {:mvn/version "3.4.333"}
|
||||
buddy/buddy-hashers {:mvn/version "2.0.167"}
|
||||
buddy/buddy-sign {:mvn/version "3.5.351"}
|
||||
|
||||
com.github.ben-manes.caffeine/caffeine {:mvn/version "3.1.2"}
|
||||
com.github.ben-manes.caffeine/caffeine {:mvn/version "3.1.6"}
|
||||
|
||||
org.jsoup/jsoup {:mvn/version "1.15.3"}
|
||||
org.jsoup/jsoup {:mvn/version "1.16.1"}
|
||||
org.im4java/im4java
|
||||
{:git/tag "1.4.0-penpot-2"
|
||||
:git/sha "e2b3e16"
|
||||
@@ -48,14 +49,14 @@
|
||||
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"}
|
||||
integrant/integrant {:mvn/version "0.8.1"}
|
||||
|
||||
dawran6/emoji {:mvn/version "0.1.5"}
|
||||
markdown-clj/markdown-clj {:mvn/version "1.11.4"}
|
||||
|
||||
;; Pretty Print specs
|
||||
pretty-spec/pretty-spec {:mvn/version "0.1.4"}
|
||||
software.amazon.awssdk/s3 {:mvn/version "2.19.8"}
|
||||
software.amazon.awssdk/s3 {:mvn/version "2.20.96"}
|
||||
}
|
||||
|
||||
:paths ["src" "resources" "target/classes"]
|
||||
@@ -70,10 +71,9 @@
|
||||
mockery/mockery {:mvn/version "RELEASE"}}
|
||||
:extra-paths ["test" "dev"]}
|
||||
|
||||
|
||||
:build
|
||||
{:extra-deps
|
||||
{io.github.clojure/tools.build {:git/tag "v0.9.0" :git/sha "8c93e0c"}}
|
||||
{io.github.clojure/tools.build {:git/tag "v0.9.3" :git/sha "e537cd1"}}
|
||||
:ns-default build}
|
||||
|
||||
:test
|
||||
|
||||
@@ -8,10 +8,15 @@
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.fressian :as fres]
|
||||
[app.common.geom.matrix :as gmt]
|
||||
[app.common.logging :as l]
|
||||
[app.common.perf :as perf]
|
||||
[app.common.pprint :as pp]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.schema.desc-js-like :as smdj]
|
||||
[app.common.schema.desc-native :as smdn]
|
||||
[app.common.schema.generators :as sg]
|
||||
[app.common.spec :as us]
|
||||
[app.common.transit :as t]
|
||||
[app.common.uuid :as uuid]
|
||||
@@ -20,7 +25,6 @@
|
||||
[app.srepl.helpers]
|
||||
[app.srepl.main :as srepl]
|
||||
[app.util.blob :as blob]
|
||||
[app.util.fressian :as fres]
|
||||
[app.util.json :as json]
|
||||
[app.util.time :as dt]
|
||||
[clj-async-profiler.core :as prof]
|
||||
@@ -31,13 +35,20 @@
|
||||
[clojure.spec.alpha :as s]
|
||||
[clojure.stacktrace :as trace]
|
||||
[clojure.test :as test]
|
||||
[clojure.test.check.generators :as gen]
|
||||
[clojure.test.check.generators :as tgen]
|
||||
[clojure.tools.namespace.repl :as repl]
|
||||
[clojure.walk :refer [macroexpand-all]]
|
||||
[criterium.core :as crit]
|
||||
[cuerdas.core :as str]
|
||||
[datoteka.core]
|
||||
[integrant.core :as ig]))
|
||||
[integrant.core :as ig]
|
||||
[malli.core :as m]
|
||||
[malli.dev.pretty :as mdp]
|
||||
[malli.error :as me]
|
||||
[malli.generator :as mg]
|
||||
[malli.registry :as mr]
|
||||
[malli.transform :as mt]
|
||||
[malli.util :as mu]))
|
||||
|
||||
(repl/disable-reload! (find-ns 'integrant.core))
|
||||
(set! *warn-on-reflection* true)
|
||||
@@ -130,3 +141,39 @@
|
||||
(add-tap #(locking debug-tap
|
||||
(prn "tap debug:" %)))
|
||||
1))
|
||||
|
||||
|
||||
(sm/def! ::test
|
||||
[:map {:title "Foo"}
|
||||
[:x :int]
|
||||
[:y {:min 0} :double]
|
||||
[:bar
|
||||
[:map {:title "Bar"}
|
||||
[:z :string]
|
||||
[:v ::sm/uuid]]]
|
||||
[:items
|
||||
[:vector ::dt/instant]]])
|
||||
|
||||
(sm/def! ::test2
|
||||
[:multi {:title "Foo" :dispatch :type}
|
||||
[:x
|
||||
[:map {:title "FooX"}
|
||||
[:type [:= :x]]
|
||||
[:x :int]]]
|
||||
[:y
|
||||
[:map
|
||||
[:type [:= :x]]
|
||||
[:y [::sm/one-of #{:a :b :c}]]]]
|
||||
[:z
|
||||
[:map {:title "FooZ"}
|
||||
[:z
|
||||
[:multi {:title "Bar" :dispatch :type}
|
||||
[:a
|
||||
[:map
|
||||
[:type [:= :a]]
|
||||
[:a :int]]]
|
||||
[:b
|
||||
[:map
|
||||
[:type [:= :b]]
|
||||
[:b :int]]]]]]]])
|
||||
|
||||
|
||||
3
backend/resources/app/assets/swagger-ui-4.18.3.css
Normal file
3
backend/resources/app/assets/swagger-ui-4.18.3.css
Normal file
File diff suppressed because one or more lines are too long
3
backend/resources/app/assets/swagger-ui-4.18.3.js
Normal file
3
backend/resources/app/assets/swagger-ui-4.18.3.js
Normal file
File diff suppressed because one or more lines are too long
@@ -1,66 +0,0 @@
|
||||
<mjml>
|
||||
|
||||
<mj-head>
|
||||
<mj-font name="Source Sans Pro" href="https://fonts.googleapis.com/css?family=Source%20Sans%20Pro" />
|
||||
<mj-attributes>
|
||||
<mj-text font-family="Source Sans Pro, sans-serif" font-size="16px" color="#000000" line-height="150%" />
|
||||
<mj-button background-color="#31EFB8" color="#1F1F1F" font-family="Source Sans Pro, sans-serif" font-size="16px" />
|
||||
</mj-attributes>
|
||||
</mj-head>
|
||||
|
||||
<mj-body background-color="#E5E5E5">
|
||||
|
||||
<mj-section padding="0">
|
||||
<mj-column>
|
||||
<mj-image src="{{ public-uri }}/images/email/uxbox-title.png"
|
||||
width="97px" height="32px" align="left" padding="16px" />
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
<mj-section background-color="#FFFFFF">
|
||||
<mj-column>
|
||||
<mj-text font-size="24px" font-weight="600">Hello {{name}}!</mj-text>
|
||||
<mj-text>We received a request to change your current email to {{ pending-email }}.</mj-text>
|
||||
<mj-text>Click to the link below to confirm the change:</mj-text>
|
||||
<mj-button href="{{ public-uri }}/#/auth/verify-token?token={{token}}">
|
||||
Confirm email change
|
||||
</mj-button>
|
||||
<mj-text>
|
||||
If you received this email by mistake, please consider changing your password
|
||||
for security reasons.
|
||||
</mj-text>
|
||||
<mj-text>Enjoy!</mj-text>
|
||||
<mj-text>The Penpot team.</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
<mj-section padding="24px 0 0 0">
|
||||
<mj-column width="425px">
|
||||
<mj-text align="center" font-size="14px" color="#64666A">
|
||||
Penpot is the first Open Source design and prototyping platform meant for cross-domain teams.
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
<mj-section padding="0">
|
||||
<mj-column>
|
||||
<mj-social icon-size="24px" mode="horizontal">
|
||||
<mj-social-element src="{{ public-uri }}/images/email/logo-uxbox.png" href="https://penpot.app/" padding="0 8px" />
|
||||
<mj-social-element src="{{ public-uri }}/images/email/logo-twitter.png" href="https://twitter.com/penpotapp" padding="0 8px" />
|
||||
<mj-social-element src="{{ public-uri }}/images/email/logo-github.png" href="https://github.com/penpot/" padding="0 8px" />
|
||||
<mj-social-element src="{{ public-uri }}/images/email/logo-instagram.png" href="https://www.instagram.com/penpot.app/" padding="0 8px" />
|
||||
<mj-social-element src="{{ public-uri }}/images/email/logo-taiga.png" href="https://tree.taiga.io/project/penpot" padding="0 8px" />
|
||||
</mj-social>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
<mj-section padding="0 0 24px 0">
|
||||
<mj-column>
|
||||
<mj-text align="center" font-size="14px" color="#64666A" line-height="150%">
|
||||
Penpot © 2020 | Made with <3 and Open Source
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
</mg-body>
|
||||
</mjml>
|
||||
@@ -1,59 +0,0 @@
|
||||
<mjml>
|
||||
<mj-head>
|
||||
<mj-font name="Source Sans Pro" href="https://fonts.googleapis.com/css?family=Source%20Sans%20Pro" />
|
||||
<mj-attributes>
|
||||
<mj-text font-family="Source Sans Pro, sans-serif" font-size="16px" color="#000000" line-height="150%" />
|
||||
<mj-button background-color="#31EFB8" color="#1F1F1F" font-family="Source Sans Pro, sans-serif" font-size="16px" />
|
||||
</mj-attributes>
|
||||
</mj-head>
|
||||
<mj-body background-color="#E5E5E5">
|
||||
<mj-section padding="0">
|
||||
<mj-column>
|
||||
<mj-image src="{{ public-uri }}/images/email/uxbox-title.png"
|
||||
width="97px" height="32px" align="left" padding="16px" />
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section background-color="#FFFFFF">
|
||||
<mj-column>
|
||||
<mj-text font-size="24px" font-weight="600">Hello!</mj-text>
|
||||
<mj-text>
|
||||
{{invited-by}} has invited you to join the team “{{ team }}”.
|
||||
</mj-text>
|
||||
<mj-button href="{{ public-uri }}/#/auth/verify-token?token={{token}}">
|
||||
Accept invite
|
||||
</mj-button>
|
||||
<mj-text>Enjoy!</mj-text>
|
||||
<mj-text>The Penpot team.</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
<mj-section padding="24px 0 0 0">
|
||||
<mj-column width="425px">
|
||||
<mj-text align="center" font-size="14px" color="#64666A">
|
||||
Penpot is the first Open Source design and prototyping platform meant for cross-domain teams.
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
<mj-section padding="0">
|
||||
<mj-column>
|
||||
<mj-social icon-size="24px" mode="horizontal">
|
||||
<mj-social-element src="{{ public-uri }}/images/email/logo-uxbox.png" href="https://penpot.app/" padding="0 8px" />
|
||||
<mj-social-element src="{{ public-uri }}/images/email/logo-twitter.png" href="https://twitter.com/penpotapp" padding="0 8px" />
|
||||
<mj-social-element src="{{ public-uri }}/images/email/logo-github.png" href="https://github.com/penpot/" padding="0 8px" />
|
||||
<mj-social-element src="{{ public-uri }}/images/email/logo-instagram.png" href="https://www.instagram.com/penpot.app/" padding="0 8px" />
|
||||
<mj-social-element src="{{ public-uri }}/images/email/logo-taiga.png" href="https://tree.taiga.io/project/penpot" padding="0 8px" />
|
||||
</mj-social>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
<mj-section padding="0 0 24px 0">
|
||||
<mj-column>
|
||||
<mj-text align="center" font-size="14px" color="#64666A" line-height="150%">
|
||||
Penpot © 2020 | Made with <3 and Open Source
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
</mg-body>
|
||||
</mjml>
|
||||
@@ -1,68 +0,0 @@
|
||||
<mjml>
|
||||
|
||||
<mj-head>
|
||||
<mj-font name="Source Sans Pro" href="https://fonts.googleapis.com/css?family=Source%20Sans%20Pro" />
|
||||
<mj-attributes>
|
||||
<mj-text font-family="Source Sans Pro, sans-serif" font-size="16px" color="#000000" line-height="150%" />
|
||||
<mj-button background-color="#31EFB8" color="#1F1F1F" font-family="Source Sans Pro, sans-serif" font-size="16px" />
|
||||
</mj-attributes>
|
||||
</mj-head>
|
||||
|
||||
<mj-body background-color="#E5E5E5">
|
||||
|
||||
<mj-section padding="0">
|
||||
<mj-column>
|
||||
<mj-image src="{{ public-uri }}/images/email/uxbox-title.png"
|
||||
width="97px" height="32px" align="left" padding="16px" />
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
<mj-section background-color="#FFFFFF">
|
||||
<mj-column>
|
||||
<mj-text font-size="24px" font-weight="600">Hello {{name}}!</mj-text>
|
||||
<mj-text>
|
||||
We have received a request to reset your password. Click the link
|
||||
below to choose a new one:
|
||||
</mj-text>
|
||||
<mj-button href="{{ public-uri }}/#/auth/recovery?token={{token}}">
|
||||
Reset password
|
||||
</mj-button>
|
||||
<mj-text>
|
||||
If you received this email by mistake, you can safely ignore
|
||||
it. Your password won't be changed.
|
||||
</mj-text>
|
||||
<mj-text>Enjoy!</mj-text>
|
||||
<mj-text>The Penpot team.</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
<mj-section padding="24px 0 0 0">
|
||||
<mj-column width="425px">
|
||||
<mj-text align="center" font-size="14px" color="#64666A">
|
||||
Penpot is the first Open Source design and prototyping platform meant for cross-domain teams.
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
<mj-section padding="0">
|
||||
<mj-column>
|
||||
<mj-social icon-size="24px" mode="horizontal">
|
||||
<mj-social-element src="{{ public-uri }}/images/email/logo-uxbox.png" href="https://penpot.app/" padding="0 8px" />
|
||||
<mj-social-element src="{{ public-uri }}/images/email/logo-twitter.png" href="https://twitter.com/penpotapp" padding="0 8px" />
|
||||
<mj-social-element src="{{ public-uri }}/images/email/logo-github.png" href="https://github.com/penpot/" padding="0 8px" />
|
||||
<mj-social-element src="{{ public-uri }}/images/email/logo-instagram.png" href="https://www.instagram.com/penpot.app/" padding="0 8px" />
|
||||
<mj-social-element src="{{ public-uri }}/images/email/logo-taiga.png" href="https://tree.taiga.io/project/penpot" padding="0 8px" />
|
||||
</mj-social>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
<mj-section padding="0 0 24px 0">
|
||||
<mj-column>
|
||||
<mj-text align="center" font-size="14px" color="#64666A" line-height="150%">
|
||||
Penpot © 2020 | Made with <3 and Open Source
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
</mg-body>
|
||||
</mjml>
|
||||
@@ -1,65 +0,0 @@
|
||||
<mjml>
|
||||
|
||||
<mj-head>
|
||||
<mj-font name="Source Sans Pro" href="https://fonts.googleapis.com/css?family=Source%20Sans%20Pro" />
|
||||
<mj-attributes>
|
||||
<mj-text font-family="Source Sans Pro, sans-serif" font-size="16px" color="#000000" line-height="150%" />
|
||||
<mj-button background-color="#31EFB8" color="#1F1F1F" font-family="Source Sans Pro, sans-serif" font-size="16px" />
|
||||
</mj-attributes>
|
||||
</mj-head>
|
||||
|
||||
<mj-body background-color="#E5E5E5">
|
||||
|
||||
<mj-section padding="0">
|
||||
<mj-column>
|
||||
<mj-image src="{{ public-uri }}/images/email/uxbox-title.png"
|
||||
width="97px" height="32px" align="left" padding="16px" />
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
<mj-section background-color="#FFFFFF">
|
||||
<mj-column>
|
||||
<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 and get started building mockups and
|
||||
prototypes today!
|
||||
</mj-text>
|
||||
<mj-button href="{{ public-uri }}/#/auth/verify-token?token={{token}}">
|
||||
Verify email
|
||||
</mj-button>
|
||||
<mj-text>Enjoy!</mj-text>
|
||||
<mj-text>The Penpot team.</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
<mj-section padding="24px 0 0 0">
|
||||
<mj-column width="425px">
|
||||
<mj-text align="center" font-size="14px" color="#64666A">
|
||||
Penpot is the first Open Source design and prototyping platform meant for cross-domain teams.
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
<mj-section padding="0">
|
||||
<mj-column>
|
||||
<mj-social icon-size="24px" mode="horizontal">
|
||||
<mj-social-element src="{{ public-uri }}/images/email/logo-uxbox.png" href="https://penpot.app/" padding="0 8px" />
|
||||
<mj-social-element src="{{ public-uri }}/images/email/logo-twitter.png" href="https://twitter.com/penpotapp" padding="0 8px" />
|
||||
<mj-social-element src="{{ public-uri }}/images/email/logo-github.png" href="https://github.com/penpot/" padding="0 8px" />
|
||||
<mj-social-element src="{{ public-uri }}/images/email/logo-instagram.png" href="https://www.instagram.com/penpot.app/" padding="0 8px" />
|
||||
<mj-social-element src="{{ public-uri }}/images/email/logo-taiga.png" href="https://tree.taiga.io/project/penpot" padding="0 8px" />
|
||||
</mj-social>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
<mj-section padding="0 0 24px 0">
|
||||
<mj-column>
|
||||
<mj-text align="center" font-size="14px" color="#64666A" line-height="150%">
|
||||
Penpot © 2020 | Made with <3 and Open Source
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
</mg-body>
|
||||
</mjml>
|
||||
@@ -1,36 +1,30 @@
|
||||
[{:id "tutorial-for-beginners"
|
||||
[{:id "material-design-3"
|
||||
:name "Material Design 3"
|
||||
:file-uri "https://github.com/penpot/penpot-files/raw/main/Material%20Design%203.penpot"}
|
||||
{:id "tutorial-for-beginners"
|
||||
:name "Tutorial for beginners"
|
||||
:thumbnail-uri "https://penpot.app/images/libraries/tutorial-for-beginners.jpg"
|
||||
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/tutorial-for-beginners.penpot"}
|
||||
{:id "penpot-design-system"
|
||||
:name "Penpot Design System"
|
||||
:thumbnail-uri "https://penpot.app/images/libraries/cover-ds-penpot.jpg"
|
||||
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/Penpot-Design-system.penpot"}
|
||||
{:id "flex-layout-playground"
|
||||
:name "Flex Layout Playground"
|
||||
:file-uri "https://github.com/penpot/penpot-files/raw/main/Flex%20Layout%20Playground.penpot"}
|
||||
{:id "wireframing-kit"
|
||||
:name "Wireframing Kit"
|
||||
:thumbnail-uri "https://penpot.app/images/libraries/cover-wireframes.jpg"
|
||||
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/wireframing-kit.penpot"}
|
||||
{:id "ant-design"
|
||||
:name "Ant Design UI Kit (lite)"
|
||||
:thumbnail-uri "https://penpot.app/images/libraries/cover-ant-design.jpg"
|
||||
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/Ant-Design-UI-Kit-Lite.penpot"}
|
||||
{:id "cocomaterial"
|
||||
:name "Cocomaterial"
|
||||
:thumbnail-uri "https://penpot.app/images/libraries/cover-cocomaterial.jpg"
|
||||
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/Cocomaterial.penpot"}
|
||||
{:id "circum-icons"
|
||||
:name "Circum Icons pack"
|
||||
:thumbnail-uri "https://penpot.app/images/libraries/cover-circum.jpg"
|
||||
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/CircumIcons.penpot"}
|
||||
{:id "coreui"
|
||||
:name "CoreUI"
|
||||
:thumbnail-uri "https://penpot.app/images/libraries/cover-coreui.jpg"
|
||||
:file-uri "https://github.com/penpot/penpot-files/raw/main/CoreUI%20DesignSystem%20(DEMO).penpot"}
|
||||
{:id "whiteboarding-kit"
|
||||
:name "Whiteboarding Kit"
|
||||
:thumbnail-uri "https://penpot.app/images/libraries/cover-whiteboards.jpg"
|
||||
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/Whiteboarding-mapping-kit.penpot"}
|
||||
{:id "material-design-baseline"
|
||||
:name "Material Design (baseline)"
|
||||
:thumbnail-uri "https://penpot.app/images/libraries/cover-material.jpg"
|
||||
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/Material-Design-Kit.penpot"}]
|
||||
:file-uri "https://github.com/penpot/penpot-files/raw/binary-files/Whiteboarding-mapping-kit.penpot"}]
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<li class="rpc-item">
|
||||
<div class="rpc-row-info">
|
||||
{# <div class="type">{{item.type}}</div> #}
|
||||
<div class="module">{{item.module}}:</div>
|
||||
<div class="name">{{item.name}}</div>
|
||||
<div class="tags">
|
||||
@@ -15,19 +14,27 @@
|
||||
<span>AUTH</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if item.webhook %}
|
||||
<span class="tag">
|
||||
<span>WEBHOOK</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if item.params-schema-js %}
|
||||
<span class="tag">
|
||||
<span>SC</span>
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="tag">
|
||||
<span>SP</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rpc-row-detail hidden">
|
||||
<h3>DOCSTRING:</h3>
|
||||
<h4>DOCSTRING:</h4>
|
||||
|
||||
<section class="padded-section">
|
||||
|
||||
{% if item.added %}
|
||||
<p class="small"><strong>Added:</strong> on v{{item.added}}</p>
|
||||
{% endif %}
|
||||
@@ -36,13 +43,18 @@
|
||||
<p class="small"><strong>Deprecated:</strong> since v{{item.deprecated}}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if item.entrypoint %}
|
||||
<p class="small"><strong>URI:</strong> <a href="{{item.entrypoint}}">{{item.entrypoint}}</a></p>
|
||||
{% endif %}
|
||||
|
||||
{% if item.docs %}
|
||||
<p class="docstring"> {{item.docs}}</p>
|
||||
{% endif %}
|
||||
|
||||
</section>
|
||||
|
||||
{% if item.changes %}
|
||||
<h3>CHANGES:</h3>
|
||||
<h4>CHANGES:</h4>
|
||||
<section class="padded-section">
|
||||
|
||||
<ul class="changes">
|
||||
@@ -53,9 +65,55 @@
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<h3>SPEC EXPLAIN:</h3>
|
||||
<section class="padded-section">
|
||||
<pre class="spec-explain">{{item.spec}}</pre>
|
||||
</section>
|
||||
{% if item.spec %}
|
||||
<h4>PARAMS (SPEC):</h4>
|
||||
<section class="padded-section">
|
||||
<pre class="spec-explain">{{item.spec}}</pre>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if param-style = "js" %}
|
||||
{% if item.params-schema-js %}
|
||||
<h4>PARAMS:</h4>
|
||||
<section class="padded-section">
|
||||
<pre class="params-schema">{{item.params-schema-js}}</pre>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if item.result-schema-js %}
|
||||
<h4>RESPONSE:</h4>
|
||||
<section class="padded-section">
|
||||
<pre class="result">{{item.result-schema-js}}</pre>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if item.webhook-schema-js %}
|
||||
<h4>WEBHOOK PAYLOAD:</h4>
|
||||
<section class="padded-section">
|
||||
<pre class="webhook">{{item.webhook-schema-js}}</pre>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if item.params-schema-clj %}
|
||||
<h4>PARAMS:</h4>
|
||||
<section class="padded-section">
|
||||
<pre class="params-schema">{{item.params-schema-clj}}</pre>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if item.result-schema-clj %}
|
||||
<h4>RESPONSE:</h4>
|
||||
<section class="padded-section">
|
||||
<pre class="result">{{item.result-schema-clj}}</pre>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if item.webhook-schema-clj %}
|
||||
<h4>WEBHOOK PAYLOAD:</h4>
|
||||
<section class="padded-section">
|
||||
<pre class="webhook">{{item.webhook-schema-clj}}</pre>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -27,12 +27,78 @@ main {
|
||||
header {
|
||||
border-bottom: 1px solid #c0c0c0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rpc-doc-content {
|
||||
header .menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 5px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
header .menu nav {
|
||||
list-style: none;
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
display: flex;
|
||||
width: 45px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
header .menu nav > a {
|
||||
list-style: none;
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
header .menu nav > a.selected {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
b {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-weight: 400;
|
||||
font-size: 11px;
|
||||
margin-top: 20px;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-weight: 300;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.doc-content {
|
||||
margin-top: 20px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* border: 1px solid red; */
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.doc-content p {
|
||||
line-height: 22px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.doc-content h3 {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.rpc-doc-content {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -65,7 +131,7 @@ header {
|
||||
.rpc-row-info {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
background-color: #eeeeee;
|
||||
background-color: #e5e5e5;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
@@ -108,6 +174,8 @@ header {
|
||||
.rpc-row-detail {
|
||||
padding: 5px 10px;
|
||||
padding-bottom: 20px;
|
||||
border-left: 2px solid #e5e5e5;
|
||||
border-right: 2px solid #e5e5e5;
|
||||
}
|
||||
|
||||
.rpc-row-detail p {
|
||||
@@ -143,3 +211,7 @@ header {
|
||||
p.small strong {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
p.small a {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
@@ -20,26 +20,70 @@
|
||||
<main>
|
||||
<header>
|
||||
<h1>Penpot API Documentation (v{{version}})</h1>
|
||||
<small class="menu">
|
||||
[
|
||||
<nav>
|
||||
<a href="?type=js" {% if param-style = "js" %}class="selected"{% endif %}>JS</a>
|
||||
<a href="?type=clj" {% if param-style = "cljs" %}class="selected"{% endif %}>CLJ</a>
|
||||
</nav>
|
||||
]
|
||||
</small>
|
||||
</header>
|
||||
<section class="doc-content">
|
||||
<h2>INTRODUCTION</h2>
|
||||
<p>This documentation is intended to be a general overview of the penpot RPC API.
|
||||
If you prefer, you can use <a href="/api/openapi.json">OpenAPI</a>
|
||||
and/or <a href="/api/openapi">SwaggerUI</a> as alternative.</p>
|
||||
|
||||
<h2>GENERAL NOTES</h2>
|
||||
|
||||
<h3>Authentication</h3>
|
||||
<p>The penpot backend right now offerts two way for authenticate the request:
|
||||
<b>cookies</b> (the same mechanism that we use ourselves on accessing the API from the
|
||||
web application) and <b>access tokens</b>.</p>
|
||||
|
||||
<p>The cookie can be obtained using the <b>`login-with-password`</b> rpc method,
|
||||
on successful login it sets the <b>`auth-token`</b> cookie with the session
|
||||
token.</p>
|
||||
|
||||
<p>The access token can be obtained on the appropriate section on profile settings
|
||||
and it should be provided using <b>`Authorization`</b> header with <b>`Token
|
||||
<token-string>`</b> value.</p>
|
||||
|
||||
<h3>Content Negotiation</h3>
|
||||
<p>The penpot API by default operates indistinctly with: <b>`application/json`</b>
|
||||
and <b>`application/transit+json`</b> content types. You should specify the
|
||||
desired content-type on the <b>`Accept`</b> header, the transit encoding is used
|
||||
by default.</p>
|
||||
|
||||
|
||||
<h3>Limits</h3>
|
||||
<p>The rate limit work per user basis (this means that different api keys share
|
||||
the same rate limit). For now the limits are not documented because we are
|
||||
studying and analyzing the data. As a general rule, it should not be abused, if an
|
||||
abusive use is detected, we will proceed to block the user's access to the
|
||||
API.</p>
|
||||
|
||||
<h3>Webhooks</h3>
|
||||
<p>All methods that emit webhook events are marked with flag <b>WEBHOOK</b>, the
|
||||
data structure defined on each method represents the <i>payload</i> of the
|
||||
event.</p>
|
||||
<p>The webhook event structure has this aspect:</p>
|
||||
<br/>
|
||||
|
||||
<pre>
|
||||
{
|
||||
"id": "db601c95-045f-808b-8002-362f08fcb621",
|
||||
"name": "rename-file",
|
||||
"props": <payload>,
|
||||
"profileId": "db601c95-045f-808b-8002-361312e63531"
|
||||
}
|
||||
</pre>
|
||||
</section>
|
||||
<section class="rpc-doc-content">
|
||||
|
||||
<h2>RPC COMMAND METHODS:</h2>
|
||||
<h2>RPC METHODS REFERENCE:</h2>
|
||||
<ul class="rpc-items">
|
||||
{% for item in command-methods %}
|
||||
{% include "app/templates/api-doc-entry.tmpl" with item=item %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<h2>RPC QUERY METHODS:</h2>
|
||||
<ul class="rpc-items">
|
||||
{% for item in query-methods %}
|
||||
{% include "app/templates/api-doc-entry.tmpl" with item=item %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<h2>RPC MUTATION METHODS:</h2>
|
||||
<ul class="rpc-items">
|
||||
{% for item in mutation-methods %}
|
||||
{% for item in methods %}
|
||||
{% include "app/templates/api-doc-entry.tmpl" with item=item %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
112
backend/resources/app/templates/error-report.v2.tmpl
Normal file
112
backend/resources/app/templates/error-report.v2.tmpl
Normal file
@@ -0,0 +1,112 @@
|
||||
{% extends "app/templates/base.tmpl" %}
|
||||
|
||||
{% block title %}
|
||||
penpot - error report v2 {{id}}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav>
|
||||
<div>[<a href="/dbg/error">⮜</a>]</div>
|
||||
<div>[<a href="#message">message</a>]</div>
|
||||
<div>[<a href="#props">props</a>]</div>
|
||||
<div>[<a href="#context">context</a>]</div>
|
||||
{% if params %}
|
||||
<div>[<a href="#params">request params</a>]</div>
|
||||
{% endif %}
|
||||
{% 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="message" class="table-key">MESSAGE: </div>
|
||||
|
||||
<div class="table-val">
|
||||
<h1>{{hint}}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-row multiline">
|
||||
<div id="props" class="table-key">LOG PROPS: </div>
|
||||
<div class="table-val">
|
||||
<pre>{{props}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-row multiline">
|
||||
<div id="context" class="table-key">CONTEXT: </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 %}
|
||||
100
backend/resources/app/templates/error-report.v3.tmpl
Normal file
100
backend/resources/app/templates/error-report.v3.tmpl
Normal file
@@ -0,0 +1,100 @@
|
||||
{% extends "app/templates/base.tmpl" %}
|
||||
|
||||
{% block title %}
|
||||
penpot - error report v2 {{id}}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav>
|
||||
<div>[<a href="/dbg/error">⮜</a>]</div>
|
||||
<div>[<a href="#message">message</a>]</div>
|
||||
<div>[<a href="#props">props</a>]</div>
|
||||
<div>[<a href="#context">context</a>]</div>
|
||||
{% if params %}
|
||||
<div>[<a href="#params">params</a>]</div>
|
||||
{% endif %}
|
||||
{% if data %}
|
||||
<div>[<a href="#edata">data</a>]</div>
|
||||
{% endif %}
|
||||
{% if explain %}
|
||||
<div>[<a href="#explain">explain</a>]</div>
|
||||
{% endif %}
|
||||
{% if value %}
|
||||
<div>[<a href="#value">value</a>]</div>
|
||||
{% endif %}
|
||||
{% if trace %}
|
||||
<div>[<a href="#trace">trace</a>]</div>
|
||||
{% endif %}
|
||||
</nav>
|
||||
<main>
|
||||
<div class="table">
|
||||
<div class="table-row multiline">
|
||||
<div id="message" class="table-key">MESSAGE: </div>
|
||||
|
||||
<div class="table-val">
|
||||
<h1>{{hint}}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-row multiline">
|
||||
<div id="props" class="table-key">LOG PROPS: </div>
|
||||
<div class="table-val">
|
||||
<pre>{{props}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-row multiline">
|
||||
<div id="context" class="table-key">CONTEXT: </div>
|
||||
|
||||
<div class="table-val">
|
||||
<pre>{{context}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if params %}
|
||||
<div class="table-row multiline">
|
||||
<div id="params" class="table-key">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">DATA: </div>
|
||||
<div class="table-val">
|
||||
<pre>{{data}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if value %}
|
||||
<div class="table-row multiline">
|
||||
<div id="value" class="table-key">VALIDATION VALUE: </div>
|
||||
<div class="table-val">
|
||||
<pre>{{value}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if explain %}
|
||||
<div class="table-row multiline">
|
||||
<div id="explain" class="table-key">EXPLAIN: </div>
|
||||
<div class="table-val">
|
||||
<pre>{{explain}}</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 %}
|
||||
28
backend/resources/app/templates/openapi.tmpl
Normal file
28
backend/resources/app/templates/openapi.tmpl
Normal file
@@ -0,0 +1,28 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta
|
||||
name="description"
|
||||
content="SwaggerUI"
|
||||
/>
|
||||
<title>PENPOT Swagger UI</title>
|
||||
<style>{{swagger-css|safe}}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script>{{swagger-js|safe}}</script>
|
||||
<script>
|
||||
window.onload = () => {
|
||||
window.ui = SwaggerUIBundle({
|
||||
url: '{{public-uri}}/api/openapi.json',
|
||||
dom_id: '#swagger-ui',
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
],
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -23,6 +23,10 @@ input[type=text], input[type=submit] {
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
main {
|
||||
margin: 20px;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
;; Example climit.edn file
|
||||
;; Required: concurrency
|
||||
;; Optional: queue-size, ommited means Integer/MAX_VALUE
|
||||
{:update-file {:concurrency 1 :queue-size 3}
|
||||
:auth {:concurrency 128}
|
||||
:process-font {:concurrency 4 :queue-size 32}
|
||||
:process-image {:concurrency 8 :queue-size 32}
|
||||
:push-audit-events
|
||||
{:concurrency 1 :queue-size 3}}
|
||||
;; Required: permits
|
||||
;; Optional: queue, ommited means Integer/MAX_VALUE
|
||||
;; Optional: timeout, ommited means no timeout
|
||||
;; Note: queue and timeout are excluding
|
||||
{:update-file-by-id {:permits 1 :queue 3}
|
||||
:update-file {:permits 20}
|
||||
|
||||
:derive-password {:permits 8}
|
||||
:process-font {:permits 4 :queue 32}
|
||||
:process-image {:permits 8 :queue 32}
|
||||
|
||||
:submit-audit-events-by-profile
|
||||
{:permits 1 :queue 3}}
|
||||
|
||||
@@ -3,22 +3,17 @@
|
||||
<Appenders>
|
||||
<Console name="console" target="SYSTEM_OUT">
|
||||
<PatternLayout pattern="[%d{YYYY-MM-dd HH:mm:ss.SSS}] %level{length=1} %logger{36} - %msg%n"
|
||||
alwaysWriteExceptions="false" />
|
||||
alwaysWriteExceptions="true" />
|
||||
</Console>
|
||||
|
||||
<RollingFile name="main" fileName="logs/main.log" filePattern="logs/main-%i.log">
|
||||
<PatternLayout pattern="[%d{YYYY-MM-dd HH:mm:ss.SSS}] %level{length=1} %logger{36} - %msg%n"
|
||||
alwaysWriteExceptions="false" />
|
||||
alwaysWriteExceptions="true" />
|
||||
<Policies>
|
||||
<SizeBasedTriggeringPolicy size="50M"/>
|
||||
</Policies>
|
||||
<DefaultRolloverStrategy max="9"/>
|
||||
</RollingFile>
|
||||
|
||||
<JeroMQ name="zmq">
|
||||
<Property name="endpoint">tcp://localhost:45556</Property>
|
||||
<JsonLayout complete="false" compact="true" includeTimeMillis="true" stacktraceAsString="true" properties="true" />
|
||||
</JeroMQ>
|
||||
</Appenders>
|
||||
|
||||
<Loggers>
|
||||
@@ -37,17 +32,12 @@
|
||||
<Logger name="app.rpc.climit" level="info" />
|
||||
<Logger name="app.rpc.mutations.files" level="info" />
|
||||
|
||||
<Logger name="app.cli" level="debug" additivity="false">
|
||||
<AppenderRef ref="console"/>
|
||||
</Logger>
|
||||
|
||||
<Logger name="app.loggers" level="debug" additivity="false">
|
||||
<AppenderRef ref="main" level="debug" />
|
||||
</Logger>
|
||||
|
||||
<Logger name="app" level="all" additivity="false">
|
||||
<AppenderRef ref="main" level="trace" />
|
||||
<AppenderRef ref="zmq" level="debug" />
|
||||
</Logger>
|
||||
|
||||
<Logger name="user" level="trace" additivity="false">
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<Logger name="com.zaxxer.hikari" level="error" />
|
||||
<Logger name="org.postgresql" level="error" />
|
||||
|
||||
<Logger name="app.util" level="info" />
|
||||
<Logger name="app" level="info" additivity="false">
|
||||
<AppenderRef ref="console" />
|
||||
</Logger>
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
{:default
|
||||
[[:default :window "200000/h"]]
|
||||
|
||||
#{:query/teams}
|
||||
[[:burst :bucket "5/1/5s"]]
|
||||
;; #{:command/get-teams}
|
||||
;; [[:burst :bucket "5/5/5s"]]
|
||||
|
||||
#{:query/profile}
|
||||
[[:burst :bucket "100/60/1m"]]}
|
||||
;; #{:command/get-profile}
|
||||
;; [[:burst :bucket "60/60/1m"]]
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ cp scripts/manage.py target/dist/manage.py
|
||||
chmod +x target/dist/run.sh;
|
||||
chmod +x target/dist/manage.py
|
||||
|
||||
# Prefetch
|
||||
# Prefetch templates
|
||||
rm -rf builtin-templates;
|
||||
mkdir builtin-templates;
|
||||
bb ./scripts/prefetch-templates.clj resources/app/onboarding.edn builtin-templates/
|
||||
cp -r builtin-templates target/dist/
|
||||
|
||||
@@ -11,6 +11,7 @@ import json
|
||||
import socket
|
||||
import sys
|
||||
|
||||
from tabulate import tabulate
|
||||
from getpass import getpass
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@@ -58,13 +59,17 @@ def print_error(res):
|
||||
break
|
||||
|
||||
def run_cmd(params):
|
||||
expr = "(app.srepl.ext/run-json-cmd {})".format(encode(params))
|
||||
res, failed = send_eval(expr)
|
||||
if failed:
|
||||
print_error(res)
|
||||
sys.exit(-1)
|
||||
try:
|
||||
expr = "(app.srepl.ext/run-json-cmd {})".format(encode(params))
|
||||
res, failed = send_eval(expr)
|
||||
if failed:
|
||||
print_error(res)
|
||||
sys.exit(-1)
|
||||
|
||||
return res
|
||||
return res
|
||||
except Exception as cause:
|
||||
print("EXC:", str(cause))
|
||||
sys.exit(-2)
|
||||
|
||||
def create_profile(fullname, email, password):
|
||||
params = {
|
||||
@@ -96,6 +101,34 @@ def update_profile(email, fullname, password, is_active):
|
||||
else:
|
||||
print(f"No profile found with email {email}")
|
||||
|
||||
def delete_profile(email, soft):
|
||||
params = {
|
||||
"cmd": "delete-profile",
|
||||
"params": {
|
||||
"email": email,
|
||||
"soft": soft
|
||||
}
|
||||
}
|
||||
|
||||
res = run_cmd(params)
|
||||
if res is True:
|
||||
print(f"Deleted")
|
||||
else:
|
||||
print(f"No profile found with email {email}")
|
||||
|
||||
def search_profile(email):
|
||||
params = {
|
||||
"cmd": "search-profile",
|
||||
"params": {
|
||||
"email": email,
|
||||
}
|
||||
}
|
||||
|
||||
res = run_cmd(params)
|
||||
|
||||
if isinstance(res, list):
|
||||
print(tabulate(res, headers="keys"))
|
||||
|
||||
def derive_password(password):
|
||||
params = {
|
||||
"cmd": "derive-password",
|
||||
@@ -107,11 +140,13 @@ def derive_password(password):
|
||||
res = run_cmd(params)
|
||||
print(f"Derived password: \"{res}\"")
|
||||
|
||||
available_commands = [
|
||||
available_commands = (
|
||||
"create-profile",
|
||||
"update-profile",
|
||||
"derive-password"
|
||||
]
|
||||
"delete-profile",
|
||||
"search-profile",
|
||||
"derive-password",
|
||||
)
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
@@ -121,10 +156,11 @@ parser = argparse.ArgumentParser(
|
||||
|
||||
parser.add_argument("-V", "--version", action="version", version="Penpot CLI %%develop%%")
|
||||
parser.add_argument("action", action="store", choices=available_commands)
|
||||
parser.add_argument("-n", "--fullname", help="Fullname", action="store")
|
||||
parser.add_argument("-e", "--email", help="Email", action="store")
|
||||
parser.add_argument("-p", "--password", help="Password", action="store")
|
||||
parser.add_argument("-c", "--connect", help="Connect to PREPL", action="store", default="tcp://localhost:6063")
|
||||
parser.add_argument("-f", "--force", help="force operation", action="store_true")
|
||||
parser.add_argument("-n", "--fullname", help="fullname", action="store")
|
||||
parser.add_argument("-e", "--email", help="email", action="store")
|
||||
parser.add_argument("-p", "--password", help="password", action="store")
|
||||
parser.add_argument("-c", "--connect", help="connect to PREPL", action="store", default="tcp://localhost:6063")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -165,3 +201,19 @@ elif args.action == "derive-password":
|
||||
password = getpass("Password: ")
|
||||
|
||||
derive_password(password)
|
||||
|
||||
elif args.action == "delete-profile":
|
||||
email = args.email
|
||||
soft = not args.force
|
||||
|
||||
if email is None:
|
||||
email = input("Email: ")
|
||||
|
||||
delete_profile(email, soft)
|
||||
|
||||
elif args.action == "search-profile":
|
||||
email = args.email
|
||||
if email is None:
|
||||
email = input("Email: ")
|
||||
|
||||
search_profile(email)
|
||||
|
||||
@@ -4,7 +4,15 @@ export PENPOT_HOST=devenv
|
||||
export PENPOT_TENANT=dev
|
||||
export PENPOT_FLAGS="\
|
||||
$PENPOT_FLAGS \
|
||||
enable-registration
|
||||
enable-login-with-password
|
||||
enable-login-with-oidc \
|
||||
enable-login-with-google \
|
||||
enable-login-with-github \
|
||||
enable-login-with-gitlab \
|
||||
enable-backend-asserts \
|
||||
enable-fdata-storage-pointer-map \
|
||||
enable-fdata-storage-objets-map \
|
||||
enable-audit-log \
|
||||
enable-transit-readable-response \
|
||||
enable-demo-users \
|
||||
@@ -42,18 +50,39 @@ export PENPOT_ASSETS_STORAGE_BACKEND=assets-s3
|
||||
export PENPOT_STORAGE_ASSETS_S3_ENDPOINT=http://minio:9000
|
||||
export PENPOT_STORAGE_ASSETS_S3_BUCKET=penpot
|
||||
|
||||
#-J-Djdk.virtualThreadScheduler.parallelism=16
|
||||
|
||||
export OPTIONS="
|
||||
-A:jmx-remote -A:dev \
|
||||
-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-Dlog4j2.configurationFile=log4j2-devenv.xml \
|
||||
-J-XX:-OmitStackTraceInFastThrow \
|
||||
-J-XX:+UnlockDiagnosticVMOptions \
|
||||
-J-XX:+DebugNonSafepoints";
|
||||
-J-XX:+DebugNonSafepoints \
|
||||
-J-Djdk.tracePinnedThreads=full \
|
||||
-J--enable-preview";
|
||||
|
||||
# Uncomment for use the ImageMagick v7.x
|
||||
# Setup HEAP
|
||||
export OPTIONS="$OPTIONS -J-Xms50m -J-Xmx1024m"
|
||||
# export OPTIONS="$OPTIONS -J-Xms1100m -J-Xmx1100m -J-XX:+AlwaysPreTouch"
|
||||
|
||||
# Increase virtual thread pool size
|
||||
# export OPTIONS="$OPTIONS -J-Djdk.virtualThreadScheduler.parallelism=16"
|
||||
|
||||
# Disable C2 Compiler
|
||||
# export OPTIONS="$OPTIONS -J-XX:TieredStopAtLevel=1"
|
||||
|
||||
# Disable all compilers
|
||||
# export OPTIONS="$OPTIONS -J-Xint"
|
||||
|
||||
# Setup GC
|
||||
export OPTIONS="$OPTIONS -J-XX:+UseG1GC"
|
||||
|
||||
# Setup GC
|
||||
# export OPTIONS="$OPTIONS -J-XX:+UseZGC"
|
||||
|
||||
# Enable ImageMagick v7.x support
|
||||
# export OPTIONS="-J-Dim4java.useV7=true $OPTIONS";
|
||||
|
||||
export OPTIONS_EVAL="nil"
|
||||
|
||||
@@ -18,7 +18,7 @@ if [ -f ./environ ]; then
|
||||
source ./environ
|
||||
fi
|
||||
|
||||
export JVM_OPTS="-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager -Dlog4j2.configurationFile=log4j2.xml -XX:-OmitStackTraceInFastThrow $JVM_OPTS"
|
||||
export JVM_OPTS="-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager -Dlog4j2.configurationFile=log4j2.xml -XX:-OmitStackTraceInFastThrow --enable-preview $JVM_OPTS"
|
||||
|
||||
set -x
|
||||
exec $JAVA_CMD $JVM_OPTS "$@" -jar penpot.jar -m app.main
|
||||
|
||||
@@ -2,7 +2,20 @@
|
||||
|
||||
export PENPOT_HOST=devenv
|
||||
export PENPOT_TENANT=dev
|
||||
export PENPOT_FLAGS="$PENPOT_FLAGS enable-backend-asserts enable-audit-log enable-transit-readable-response enable-demo-users disable-secure-session-cookies enable-smtp enable-webhooks"
|
||||
export PENPOT_FLAGS="\
|
||||
$PENPOT_FLAGS \
|
||||
enable-prepl-server \
|
||||
enable-urepl-server \
|
||||
enable-webhooks \
|
||||
enable-backend-asserts \
|
||||
enable-audit-log \
|
||||
enable-transit-readable-response \
|
||||
enable-demo-users \
|
||||
enable-fdata-storage-pointer-map \
|
||||
enable-fdata-storage-objets-map \
|
||||
disable-secure-session-cookies \
|
||||
enable-smtp \
|
||||
enable-webhooks";
|
||||
|
||||
set -ex
|
||||
|
||||
|
||||
@@ -6,15 +6,20 @@
|
||||
|
||||
(ns app.auth
|
||||
(:require
|
||||
[buddy.hashers :as hashers]))
|
||||
[app.config :as cf]
|
||||
[buddy.hashers :as hashers]
|
||||
[cuerdas.core :as str]
|
||||
[promesa.exec :as px]))
|
||||
|
||||
(def default-params
|
||||
{:alg :argon2id
|
||||
:memory (* 32768 2) ;; 64 MiB
|
||||
:iterations 7
|
||||
:parallelism (px/get-available-processors)})
|
||||
|
||||
(defn derive-password
|
||||
[password]
|
||||
(hashers/derive password
|
||||
{:alg :argon2id
|
||||
:memory 16384
|
||||
:iterations 20
|
||||
:parallelism 2}))
|
||||
(hashers/derive password default-params))
|
||||
|
||||
(defn verify-password
|
||||
[attempt password]
|
||||
@@ -24,3 +29,16 @@
|
||||
{:update false
|
||||
:valid false})))
|
||||
|
||||
(defn email-domain-in-whitelist?
|
||||
"Returns true if email's domain is in the given whitelist or if
|
||||
given whitelist is an empty string."
|
||||
([email]
|
||||
(let [domains (cf/get :registration-domain-whitelist)]
|
||||
(email-domain-in-whitelist? domains email)))
|
||||
([domains email]
|
||||
(if (or (nil? domains) (empty? domains))
|
||||
true
|
||||
(let [[_ candidate] (-> (str/lower email)
|
||||
(str/split #"@" 2))]
|
||||
(contains? domains candidate)))))
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
(ns app.auth.oidc
|
||||
"OIDC client implementation."
|
||||
(:require
|
||||
[app.auth :as auth]
|
||||
[app.auth.oidc.providers :as-alias providers]
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
@@ -17,22 +18,20 @@
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.http.client :as http]
|
||||
[app.http.middleware :as hmw]
|
||||
[app.http.session :as session]
|
||||
[app.loggers.audit :as audit]
|
||||
[app.main :as-alias main]
|
||||
[app.rpc.queries.profile :as profile]
|
||||
[app.rpc.commands.profile :as profile]
|
||||
[app.tokens :as tokens]
|
||||
[app.util.json :as json]
|
||||
[app.util.time :as dt]
|
||||
[app.worker :as wrk]
|
||||
[buddy.sign.jwk :as jwk]
|
||||
[buddy.sign.jwt :as jwt]
|
||||
[clojure.set :as set]
|
||||
[clojure.spec.alpha :as s]
|
||||
[cuerdas.core :as str]
|
||||
[integrant.core :as ig]
|
||||
[promesa.core :as p]
|
||||
[promesa.exec :as px]
|
||||
[yetti.response :as yrs]))
|
||||
[yetti.response :as-alias yrs]))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; HELPERS
|
||||
@@ -51,36 +50,29 @@
|
||||
|
||||
(defn- discover-oidc-config
|
||||
[cfg {:keys [base-uri] :as opts}]
|
||||
(let [discovery-uri (u/join base-uri ".well-known/openid-configuration")
|
||||
response (ex/try! (http/req! cfg
|
||||
{:method :get :uri (str discovery-uri)}
|
||||
{: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/decode (:body response))
|
||||
(let [uri (dm/str (u/join base-uri ".well-known/openid-configuration"))
|
||||
rsp (http/req! cfg {:method :get :uri uri} {:sync? true})]
|
||||
(if (= 200 (:status rsp))
|
||||
(let [data (-> rsp :body json/decode)
|
||||
token-uri (get data :token_endpoint)
|
||||
auth-uri (get data :authorization_endpoint)
|
||||
user-uri (get data :userinfo_endpoint)]
|
||||
user-uri (get data :userinfo_endpoint)
|
||||
jwks-uri (get data :jwks_uri)]
|
||||
|
||||
(l/debug :hint "oidc uris discovered"
|
||||
:token-uri token-uri
|
||||
:auth-uri auth-uri
|
||||
:user-uri user-uri)
|
||||
:user-uri user-uri
|
||||
:jwks-uri jwks-uri)
|
||||
|
||||
{:token-uri token-uri
|
||||
:auth-uri auth-uri
|
||||
:user-uri user-uri})
|
||||
|
||||
:else
|
||||
:user-uri user-uri
|
||||
:jwks-uri jwks-uri})
|
||||
(do
|
||||
(l/warn :hint "unable to discover OIDC configuration"
|
||||
:uri (str discovery-uri)
|
||||
:response-status-code (:status response))
|
||||
:discover-uri uri
|
||||
:http-status (:status rsp))
|
||||
nil))))
|
||||
|
||||
(defn- prepare-oidc-opts
|
||||
@@ -91,6 +83,7 @@
|
||||
:token-uri (cf/get :oidc-token-uri)
|
||||
:auth-uri (cf/get :oidc-auth-uri)
|
||||
:user-uri (cf/get :oidc-user-uri)
|
||||
:jwks-uri (cf/get :oidc-jwks-uri)
|
||||
:scopes (cf/get :oidc-scopes #{"openid" "profile" "email"})
|
||||
:roles-attr (cf/get :oidc-roles-attr)
|
||||
:roles (cf/get :oidc-roles)
|
||||
@@ -105,8 +98,42 @@
|
||||
(string? (:user-uri opts))
|
||||
(string? (:auth-uri opts)))
|
||||
opts
|
||||
(some-> (discover-oidc-config cfg opts)
|
||||
(merge opts {:discover? true}))))))
|
||||
(try
|
||||
(-> (discover-oidc-config cfg opts)
|
||||
(merge opts {:discover? true}))
|
||||
(catch Throwable cause
|
||||
(l/warn :hint "unable to discover OIDC configuration"
|
||||
:cause cause)))))))
|
||||
|
||||
(defn- process-oidc-jwks
|
||||
[keys]
|
||||
(reduce (fn [result {:keys [kid] :as kdata}]
|
||||
(let [pkey (ex/try! (jwk/public-key kdata))]
|
||||
(if (ex/exception? pkey)
|
||||
(do
|
||||
(l/warn :hint "unable to create public key"
|
||||
:kid (:kid kdata)
|
||||
:cause pkey)
|
||||
result)
|
||||
(assoc result kid pkey))))
|
||||
{}
|
||||
keys))
|
||||
|
||||
(defn- fetch-oidc-jwks
|
||||
[cfg {:keys [jwks-uri]}]
|
||||
(when jwks-uri
|
||||
(try
|
||||
(let [{:keys [status body]} (http/req! cfg {:method :get :uri jwks-uri} {:sync? true})]
|
||||
(if (= 200 status)
|
||||
(-> body json/decode :keys process-oidc-jwks)
|
||||
(do
|
||||
(l/warn :hint "unable to retrieve JWKs (unexpected response status code)"
|
||||
:http-status status
|
||||
:http-body body)
|
||||
nil)))
|
||||
(catch Throwable cause
|
||||
(l/warn :hint "unable to retrieve JWKs (unexpected exception)"
|
||||
:cause cause)))))
|
||||
|
||||
(defmethod ig/pre-init-spec ::providers/generic [_]
|
||||
(s/keys :req [::http/client]))
|
||||
@@ -115,7 +142,7 @@
|
||||
[_ cfg]
|
||||
(when (contains? cf/flags :login-with-oidc)
|
||||
(if-let [opts (prepare-oidc-opts cfg)]
|
||||
(do
|
||||
(let [jwks (fetch-oidc-jwks cfg opts)]
|
||||
(l/info :hint "provider initialized"
|
||||
:provider "oidc"
|
||||
:method (if (:discover? opts) "discover" "manual")
|
||||
@@ -126,8 +153,9 @@
|
||||
:user-uri (:user-uri opts)
|
||||
:token-uri (:token-uri opts)
|
||||
:roles-attr (:roles-attr opts)
|
||||
:roles (:roles opts))
|
||||
opts)
|
||||
:roles (:roles opts)
|
||||
:keys (str/join "," (map str (keys jwks))))
|
||||
(assoc opts :jwks jwks))
|
||||
(do
|
||||
(l/warn :hint "unable to initialize auth provider, missing configuration" :provider "oidc")
|
||||
nil))))
|
||||
@@ -165,21 +193,23 @@
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn- retrieve-github-email
|
||||
[cfg tdata info]
|
||||
(or (some-> info :email p/resolved)
|
||||
(->> (http/req! cfg
|
||||
{:uri "https://api.github.com/user/emails"
|
||||
:headers {"Authorization" (dm/str (:type tdata) " " (:token tdata))}
|
||||
:timeout 6000
|
||||
:method :get})
|
||||
(p/map (fn [{:keys [status body] :as response}]
|
||||
(when-not (s/int-in-range? 200 300 status)
|
||||
(ex/raise :type :internal
|
||||
:code :unable-to-retrieve-github-emails
|
||||
:hint "unable to retrieve github emails"
|
||||
:http-status status
|
||||
:http-body body))
|
||||
(->> response :body json/decode (filter :primary) first :email))))))
|
||||
[cfg tdata props]
|
||||
(or (some-> props :github/email)
|
||||
(let [params {:uri "https://api.github.com/user/emails"
|
||||
:headers {"Authorization" (dm/str (:token/type tdata) " " (:token/access tdata))}
|
||||
:timeout 6000
|
||||
:method :get}
|
||||
|
||||
{:keys [status body]} (http/req! cfg params {:sync? true})]
|
||||
|
||||
(when-not (s/int-in-range? 200 300 status)
|
||||
(ex/raise :type :internal
|
||||
:code :unable-to-retrieve-github-emails
|
||||
:hint "unable to retrieve github emails"
|
||||
:http-status status
|
||||
:http-body body))
|
||||
|
||||
(->> body json/decode (filter :primary) first :email))))
|
||||
|
||||
(defmethod ig/pre-init-spec ::providers/github [_]
|
||||
(s/keys :req [::http/client]))
|
||||
@@ -196,7 +226,7 @@
|
||||
|
||||
;; Additional hooks for provider specific way of
|
||||
;; retrieve emails.
|
||||
:get-email-fn (partial retrieve-github-email cfg)}]
|
||||
:get-email-fn (partial retrieve-github-email cfg)}]
|
||||
|
||||
(when (contains? cf/flags :login-with-github)
|
||||
(if (and (string? (:client-id opts))
|
||||
@@ -246,6 +276,11 @@
|
||||
;; HANDLERS
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn- parse-attr-path
|
||||
[provider path]
|
||||
(let [[fitem & items] (str/split path "__")]
|
||||
(into [(keyword (:name provider) fitem)] (map keyword) items)))
|
||||
|
||||
(defn- build-redirect-uri
|
||||
[{:keys [provider] :as cfg}]
|
||||
(let [public (u/uri (cf/get :public-uri))]
|
||||
@@ -270,7 +305,7 @@
|
||||
{}
|
||||
props))
|
||||
|
||||
(defn retrieve-access-token
|
||||
(defn fetch-access-token
|
||||
[{:keys [provider] :as cfg} code]
|
||||
(let [params {:client_id (:client-id provider)
|
||||
:client_secret (:client-secret provider)
|
||||
@@ -290,80 +325,82 @@
|
||||
:grant-type (:grant_type params)
|
||||
:redirect-uri (:redirect_uri params))
|
||||
|
||||
(->> (http/req! cfg req)
|
||||
(p/map (fn [{:keys [status body] :as res}]
|
||||
(l/trace :hint "access token response"
|
||||
:status status
|
||||
:body body)
|
||||
(if (= status 200)
|
||||
(let [data (json/decode body)]
|
||||
{:token (get data :access_token)
|
||||
:type (get data :token_type)})
|
||||
(ex/raise :type :internal
|
||||
:code :unable-to-retrieve-token
|
||||
:http-status status
|
||||
:http-body body)))))))
|
||||
(let [{:keys [status body]} (http/req! cfg req {:sync? true})]
|
||||
(l/trace :hint "access token response" :status status :body body)
|
||||
(if (= status 200)
|
||||
(let [data (json/decode body)]
|
||||
{:token/access (get data :access_token)
|
||||
:token/id (get data :id_token)
|
||||
:token/type (get data :token_type)})
|
||||
|
||||
(defn- retrieve-user-info
|
||||
[{:keys [provider] :as cfg} tdata]
|
||||
(letfn [(retrieve []
|
||||
(l/trace :hint "request user info"
|
||||
:uri (:user-uri provider)
|
||||
:token (obfuscate-string (:token tdata))
|
||||
:token-type (:type tdata))
|
||||
(http/req! cfg
|
||||
{:uri (:user-uri provider)
|
||||
:headers {"Authorization" (str (:type tdata) " " (:token tdata))}
|
||||
:timeout 6000
|
||||
:method :get}))
|
||||
(validate-response [response]
|
||||
(l/trace :hint "user info response"
|
||||
:status (:status response)
|
||||
:body (:body response))
|
||||
(when-not (s/int-in-range? 200 300 (:status response))
|
||||
(ex/raise :type :internal
|
||||
:code :unable-to-retrieve-user-info
|
||||
:hint "unable to retrieve user info"
|
||||
:http-status (:status response)
|
||||
:http-body (:body response)))
|
||||
response)
|
||||
(ex/raise :type :internal
|
||||
:code :unable-to-retrieve-token
|
||||
:hint "unable to retrieve token"
|
||||
:http-status status
|
||||
:http-body body)))))
|
||||
|
||||
(get-email [info]
|
||||
(defn- process-user-info
|
||||
[provider tdata info]
|
||||
(letfn [(get-email [props]
|
||||
;; Allow providers hook into this for custom email
|
||||
;; retrieval method.
|
||||
(if-let [get-email-fn (:get-email-fn provider)]
|
||||
(get-email-fn tdata info)
|
||||
(let [attr-kw (cf/get :oidc-email-attr :email)]
|
||||
(p/resolved (get info attr-kw)))))
|
||||
(get-email-fn tdata props)
|
||||
(let [attr-kw (cf/get :oidc-email-attr "email")
|
||||
attr-ph (parse-attr-path provider attr-kw)]
|
||||
(get-in props attr-ph))))
|
||||
|
||||
(get-name [info]
|
||||
(let [attr-kw (cf/get :oidc-name-attr :name)]
|
||||
(get info attr-kw)))
|
||||
(get-name [props]
|
||||
(let [attr-kw (cf/get :oidc-name-attr "name")
|
||||
attr-ph (parse-attr-path provider attr-kw)]
|
||||
(get-in props attr-ph)))
|
||||
]
|
||||
|
||||
(process-response [response]
|
||||
(p/let [info (-> response :body json/decode)
|
||||
email (get-email info)]
|
||||
{:backend (:name provider)
|
||||
:email email
|
||||
:fullname (or (get-name info) email)
|
||||
:props (->> (dissoc info :name :email)
|
||||
(qualify-props provider))}))
|
||||
(let [props (qualify-props provider info)
|
||||
email (get-email props)]
|
||||
{:backend (:name provider)
|
||||
:fullname (or (get-name props) email)
|
||||
:email email
|
||||
:props props})))
|
||||
|
||||
(validate-info [info]
|
||||
(l/trace :hint "authentication info" :info info)
|
||||
(when-not (s/valid? ::info info)
|
||||
(l/warn :hint "received incomplete profile info object (please set correct scopes)"
|
||||
:info (pr-str info))
|
||||
(ex/raise :type :internal
|
||||
:code :incomplete-user-info
|
||||
:hint "inconmplete user info"
|
||||
:info info))
|
||||
info)]
|
||||
(defn- fetch-user-info
|
||||
[{:keys [provider] :as cfg} tdata]
|
||||
(l/trace :hint "fetch user info"
|
||||
:uri (:user-uri provider)
|
||||
:token (obfuscate-string (:token/access tdata)))
|
||||
|
||||
(->> (retrieve)
|
||||
(p/fmap validate-response)
|
||||
(p/mcat process-response)
|
||||
(p/fmap validate-info))))
|
||||
(let [params {:uri (:user-uri provider)
|
||||
:headers {"Authorization" (str (:token/type tdata) " " (:token/access tdata))}
|
||||
:timeout 6000
|
||||
:method :get}
|
||||
response (http/req! cfg params {:sync? true})]
|
||||
|
||||
(l/trace :hint "user info response"
|
||||
:status (:status response)
|
||||
:body (:body response))
|
||||
|
||||
(when-not (s/int-in-range? 200 300 (:status response))
|
||||
(ex/raise :type :internal
|
||||
:code :unable-to-retrieve-user-info
|
||||
:hint "unable to retrieve user info"
|
||||
:http-status (:status response)
|
||||
:http-body (:body response)))
|
||||
|
||||
(-> response :body json/decode)))
|
||||
|
||||
(defn- get-user-info
|
||||
[{:keys [provider]} tdata]
|
||||
(try
|
||||
(let [{:keys [kid alg] :as theader} (jwt/decode-header (:token/id tdata))]
|
||||
(when-let [key (if (str/starts-with? (name alg) "hs")
|
||||
(:client-secret provider)
|
||||
(get-in provider [:jwks kid]))]
|
||||
|
||||
(let [claims (jwt/unsign (:token/id tdata) key {:alg alg})]
|
||||
(dissoc claims :exp :iss :iat :sid :aud :sub))))
|
||||
(catch Throwable cause
|
||||
(l/warn :hint "unable to get user info from JWT token (unexpected exception)"
|
||||
:cause cause))))
|
||||
|
||||
(s/def ::backend ::us/not-empty-string)
|
||||
(s/def ::email ::us/not-empty-string)
|
||||
@@ -375,77 +412,100 @@
|
||||
::fullname
|
||||
::props]))
|
||||
|
||||
(defn retrieve-info
|
||||
[{:keys [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 #{}))]
|
||||
(defn get-info
|
||||
[{:keys [provider ::main/props] :as cfg} {:keys [params] :as request}]
|
||||
(when-let [error (get params :error)]
|
||||
(ex/raise :type :internal
|
||||
:code :error-on-retrieving-code
|
||||
:error-id error
|
||||
:error-desc (get params :error_description)))
|
||||
|
||||
;; 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)
|
||||
(let [state (get params :state)
|
||||
code (get params :code)
|
||||
state (tokens/verify props {:token state :iss :oauth})
|
||||
tdata (fetch-access-token cfg code)
|
||||
info (case (cf/get :oidc-user-info-source)
|
||||
:token (get-user-info cfg tdata)
|
||||
:userinfo (fetch-user-info cfg tdata)
|
||||
(or (get-user-info cfg tdata)
|
||||
(fetch-user-info cfg tdata)))
|
||||
|
||||
(post-process [state info]
|
||||
(cond-> info
|
||||
(some? (:invitation-token state))
|
||||
(assoc :invitation-token (:invitation-token state))
|
||||
info (process-user-info provider tdata info)]
|
||||
|
||||
;; 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))))]
|
||||
(l/trace :hint "user info" :info info)
|
||||
|
||||
(when-let [error (get params :error)]
|
||||
(when-not (s/valid? ::info info)
|
||||
(l/warn :hint "received incomplete profile info object (please set correct scopes)" :info info)
|
||||
(ex/raise :type :internal
|
||||
:code :error-on-retrieving-code
|
||||
:error-id error
|
||||
:error-desc (get params :error_description)))
|
||||
:code :incomplete-user-info
|
||||
:hint "inconmplete user info"
|
||||
:info info))
|
||||
|
||||
(let [state (get params :state)
|
||||
code (get params :code)
|
||||
state (tokens/verify (::main/props cfg) {: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))))))
|
||||
;; If the provider is OIDC, we can proceed to check
|
||||
;; roles if they are defined.
|
||||
(when (and (= "oidc" (:name provider))
|
||||
(seq (:roles provider)))
|
||||
|
||||
(defn- retrieve-profile
|
||||
[{:keys [::db/pool ::wrk/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)))))
|
||||
(let [expected-roles (into #{} (:roles provider))
|
||||
current-roles (let [roles-kw (cf/get :oidc-roles-attr "roles")
|
||||
roles-ph (parse-attr-path provider roles-kw)
|
||||
roles (get-in (:props info) roles-ph)]
|
||||
(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? expected-roles current-roles)
|
||||
(ex/raise :type :internal
|
||||
:code :unable-to-auth
|
||||
:hint "not enough 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)))))
|
||||
|
||||
(defn- get-profile
|
||||
[{:keys [::db/pool] :as cfg} info]
|
||||
(dm/with-open [conn (db/open pool)]
|
||||
(some->> (:email info)
|
||||
(profile/get-profile-by-email conn))))
|
||||
|
||||
(defn- redirect-response
|
||||
[uri]
|
||||
(yrs/response :status 302 :headers {"location" (str uri)}))
|
||||
{::yrs/status 302
|
||||
::yrs/headers {"location" (str uri)}})
|
||||
|
||||
(defn- generate-error-redirect
|
||||
[_ error]
|
||||
(let [uri (-> (u/uri (cf/get :public-uri))
|
||||
(assoc :path "/#/auth/login")
|
||||
(assoc :query (u/map->query-string {:error "unable-to-auth" :hint (ex-message error)})))]
|
||||
[_ cause]
|
||||
(let [data (if (ex/error? cause) (ex-data cause) nil)
|
||||
code (or (:code data) :unexpected)
|
||||
type (or (:type data) :internal)
|
||||
hint (or (:hint data)
|
||||
(if (ex/exception? cause)
|
||||
(ex-message cause)
|
||||
(str cause)))
|
||||
|
||||
params {:error "unable-to-auth"
|
||||
:hint hint
|
||||
:type type
|
||||
:code code}
|
||||
|
||||
uri (-> (u/uri (cf/get :public-uri))
|
||||
(assoc :path "/#/auth/login")
|
||||
(assoc :query (u/map->query-string params)))]
|
||||
|
||||
(redirect-response uri)))
|
||||
|
||||
(defn- generate-redirect
|
||||
[{:keys [::session/session] :as cfg} request info profile]
|
||||
[cfg request info profile]
|
||||
(if profile
|
||||
(let [sxf (session/create-fn session (:id profile))
|
||||
(let [sxf (session/create-fn cfg (:id profile))
|
||||
token (or (:invitation-token info)
|
||||
(tokens/generate (::main/props cfg)
|
||||
{:iss :auth
|
||||
@@ -460,27 +520,32 @@
|
||||
(ex/raise :type :restriction
|
||||
:code :profile-blocked))
|
||||
|
||||
(audit/submit! cfg {:type "command"
|
||||
:name "login-with-password"
|
||||
:profile-id (:id profile)
|
||||
:ip-addr (audit/parse-client-ip request)
|
||||
:props (audit/profile->props profile)})
|
||||
(audit/submit! cfg {::audit/type "command"
|
||||
::audit/name "login-with-oidc"
|
||||
::audit/profile-id (:id profile)
|
||||
::audit/ip-addr (audit/parse-client-ip request)
|
||||
::audit/props (audit/profile->props profile)})
|
||||
|
||||
(->> (redirect-response uri)
|
||||
(sxf request)))
|
||||
|
||||
(let [info (assoc info
|
||||
:iss :prepared-register
|
||||
:is-active true
|
||||
:exp (dt/in-future {:hours 48}))
|
||||
token (tokens/generate (::main/props cfg) info)
|
||||
params (d/without-nils
|
||||
{:token token
|
||||
:fullname (:fullname info)})
|
||||
uri (-> (u/uri (cf/get :public-uri))
|
||||
(assoc :path "/#/auth/register/validate")
|
||||
(assoc :query (u/map->query-string params)))]
|
||||
(redirect-response uri))))
|
||||
|
||||
(if (auth/email-domain-in-whitelist? (:email info))
|
||||
(let [info (assoc info
|
||||
:iss :prepared-register
|
||||
:is-active true
|
||||
:exp (dt/in-future {:hours 48}))
|
||||
token (tokens/generate (::main/props cfg) info)
|
||||
params (d/without-nils
|
||||
{:token token
|
||||
:fullname (:fullname info)})
|
||||
uri (-> (u/uri (cf/get :public-uri))
|
||||
(assoc :path "/#/auth/register/validate")
|
||||
(assoc :query (u/map->query-string params)))]
|
||||
|
||||
(redirect-response uri))
|
||||
(generate-error-redirect cfg "email-domain-not-allowed"))))
|
||||
|
||||
|
||||
(defn- auth-handler
|
||||
[cfg {:keys [params] :as request}]
|
||||
@@ -491,27 +556,24 @@
|
||||
:props props
|
||||
:exp (dt/in-future "4h")})
|
||||
uri (build-auth-uri cfg state)]
|
||||
(yrs/response 200 {:redirect-uri uri})))
|
||||
{::yrs/status 200
|
||||
::yrs/body {:redirect-uri uri}}))
|
||||
|
||||
(defn- callback-handler
|
||||
[cfg request]
|
||||
(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)
|
||||
(generate-error-redirect cfg cause))]
|
||||
|
||||
(-> (process-request)
|
||||
(p/catch handle-error))))
|
||||
(try
|
||||
(let [info (get-info cfg request)
|
||||
profile (get-profile cfg info)]
|
||||
(generate-redirect cfg request info profile))
|
||||
(catch Throwable cause
|
||||
(l/error :hint "error on oauth process" :cause cause)
|
||||
(generate-error-redirect cfg cause))))
|
||||
|
||||
(def provider-lookup
|
||||
{:compile
|
||||
(fn [& _]
|
||||
(fn [handler]
|
||||
(fn [{:keys [::providers] :as cfg} request]
|
||||
(fn [handler {:keys [::providers] :as cfg}]
|
||||
(fn [request]
|
||||
(let [provider (some-> request :path-params :provider keyword)]
|
||||
(if-let [provider (get providers provider)]
|
||||
(handler (assoc cfg :provider provider) request)
|
||||
@@ -549,23 +611,21 @@
|
||||
|
||||
(s/def ::providers (s/map-of ::us/keyword (s/nilable ::provider)))
|
||||
|
||||
(s/def ::routes vector?)
|
||||
|
||||
(defmethod ig/pre-init-spec ::routes
|
||||
[_]
|
||||
(s/keys :req [::http/client
|
||||
::wrk/executor
|
||||
(s/keys :req [::session/manager
|
||||
::http/client
|
||||
::main/props
|
||||
::db/pool
|
||||
::providers
|
||||
::session/session]))
|
||||
::providers]))
|
||||
|
||||
(defmethod ig/init-key ::routes
|
||||
[_ {:keys [::wrk/executor ::session/session] :as cfg}]
|
||||
[_ cfg]
|
||||
(let [cfg (update cfg :provider d/without-nils)]
|
||||
["" {:middleware [[(:middleware session)]
|
||||
[hmw/with-dispatch executor]
|
||||
[hmw/with-config cfg]
|
||||
[provider-lookup]
|
||||
]}
|
||||
["" {:middleware [[session/authz cfg]
|
||||
[provider-lookup cfg]]}
|
||||
["/auth/oauth"
|
||||
["/:provider"
|
||||
{:handler auth-handler
|
||||
@@ -573,4 +633,3 @@
|
||||
["/:provider/callback"
|
||||
{:handler callback-handler
|
||||
:allowed-methods #{:get}}]]]))
|
||||
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC
|
||||
|
||||
(ns app.cli.manage
|
||||
"A manage cli api."
|
||||
(:require
|
||||
[app.common.logging :as l]
|
||||
[app.db :as db]
|
||||
[app.main :as main]
|
||||
[app.rpc.commands.auth :as cmd.auth]
|
||||
[app.rpc.mutations.profile :as profile]
|
||||
[app.rpc.queries.profile :refer [retrieve-profile-data-by-email]]
|
||||
[clojure.string :as str]
|
||||
[clojure.tools.cli :refer [parse-opts]]
|
||||
[integrant.core :as ig])
|
||||
(:import
|
||||
java.io.Console))
|
||||
|
||||
;; --- IMPL
|
||||
|
||||
(defn init-system
|
||||
[]
|
||||
(let [data (-> main/system-config
|
||||
(select-keys [:app.db/pool :app.metrics/metrics])
|
||||
(assoc :app.migrations/all {}))]
|
||||
(-> data ig/prep ig/init)))
|
||||
|
||||
(defn- read-from-console
|
||||
[{:keys [label type] :or {type :text}}]
|
||||
(let [^Console console (System/console)]
|
||||
(when-not console
|
||||
(l/error :hint "no console found, can proceed")
|
||||
(System/exit 1))
|
||||
|
||||
(binding [*out* (.writer console)]
|
||||
(print label " ")
|
||||
(.flush *out*))
|
||||
|
||||
(case type
|
||||
:text (.readLine console)
|
||||
:password (String. (.readPassword console)))))
|
||||
|
||||
(defn create-profile
|
||||
[options]
|
||||
(let [system (init-system)
|
||||
email (or (:email options)
|
||||
(read-from-console {:label "Email:"}))
|
||||
fullname (or (:fullname options)
|
||||
(read-from-console {:label "Full Name:"}))
|
||||
password (or (:password options)
|
||||
(read-from-console {:label "Password:"
|
||||
:type :password}))]
|
||||
(try
|
||||
(db/with-atomic [conn (:app.db/pool system)]
|
||||
(->> (cmd.auth/create-profile conn
|
||||
{:fullname fullname
|
||||
:email email
|
||||
:password password
|
||||
:is-active true
|
||||
:is-demo false})
|
||||
(cmd.auth/create-profile-relations conn)))
|
||||
|
||||
(when (pos? (:verbosity options))
|
||||
(println "User created successfully."))
|
||||
(System/exit 0)
|
||||
|
||||
(catch Exception _e
|
||||
(when (pos? (:verbosity options))
|
||||
(println "Unable to create user, already exists."))
|
||||
(System/exit 1)))))
|
||||
|
||||
(defn reset-password
|
||||
[options]
|
||||
(let [system (init-system)]
|
||||
(try
|
||||
(db/with-atomic [conn (:app.db/pool system)]
|
||||
(let [email (or (:email options)
|
||||
(read-from-console {:label "Email:"}))
|
||||
profile (retrieve-profile-data-by-email conn email)]
|
||||
(when-not profile
|
||||
(when (pos? (:verbosity options))
|
||||
(println "Profile does not exists."))
|
||||
(System/exit 1))
|
||||
|
||||
(let [password (or (:password options)
|
||||
(read-from-console {:label "Password:"
|
||||
:type :password}))]
|
||||
(profile/update-profile-password! conn (assoc profile :password password))
|
||||
(when (pos? (:verbosity options))
|
||||
(println "Password changed successfully.")))))
|
||||
(System/exit 0)
|
||||
(catch Exception e
|
||||
(when (pos? (:verbosity options))
|
||||
(println "Unable to change password."))
|
||||
(when (= 2 (:verbosity options))
|
||||
(.printStackTrace e))
|
||||
(System/exit 1)))))
|
||||
|
||||
;; --- CLI PARSE
|
||||
|
||||
(def cli-options
|
||||
;; An option with a required argument
|
||||
[["-u" "--email EMAIL" "Email Address"]
|
||||
["-p" "--password PASSWORD" "Password"]
|
||||
["-n" "--name FULLNAME" "Full Name"
|
||||
:id :fullname]
|
||||
["-v" nil "Verbosity level"
|
||||
:id :verbosity
|
||||
:default 1
|
||||
:update-fn inc]
|
||||
["-q" nil "Don't print to console"
|
||||
:id :verbosity
|
||||
:update-fn (constantly 0)]
|
||||
["-h" "--help"]])
|
||||
|
||||
(defn usage
|
||||
[options-summary]
|
||||
(->> ["Penpot CLI management."
|
||||
""
|
||||
"Usage: manage [options] action"
|
||||
""
|
||||
"Options:"
|
||||
options-summary
|
||||
""
|
||||
"Actions:"
|
||||
" create-profile Create new profile."
|
||||
" reset-password Reset profile password."
|
||||
""]
|
||||
(str/join \newline)))
|
||||
|
||||
(defn error-msg [errors]
|
||||
(str "The following errors occurred while parsing your command:\n\n"
|
||||
(str/join \newline errors)))
|
||||
|
||||
(defn validate-args
|
||||
"Validate command line arguments. Either return a map indicating the program
|
||||
should exit (with a error message, and optional ok status), or a map
|
||||
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)]
|
||||
(cond
|
||||
(:help options) ; help => exit OK with usage summary
|
||||
{:exit-message (usage summary) :ok? true}
|
||||
|
||||
errors ; errors => exit with description of errors
|
||||
{:exit-message (error-msg errors)}
|
||||
|
||||
;; custom validation on arguments
|
||||
:else
|
||||
(let [action (first arguments)]
|
||||
(if (#{"create-profile" "reset-password"} action)
|
||||
{:action (first arguments) :options options}
|
||||
{:exit-message (usage summary)})))))
|
||||
|
||||
(defn exit [status msg]
|
||||
(println msg)
|
||||
(System/exit status))
|
||||
|
||||
(defn -main
|
||||
[& args]
|
||||
(let [{:keys [action options exit-message ok?]} (validate-args args)]
|
||||
(if exit-message
|
||||
(exit (if ok? 0 1) exit-message)
|
||||
(case action
|
||||
"create-profile" (create-profile options)
|
||||
"reset-password" (reset-password options)))))
|
||||
@@ -51,7 +51,6 @@
|
||||
:database-password "penpot"
|
||||
|
||||
:default-blob-version 5
|
||||
:loggers-zmq-uri "tcp://localhost:45556"
|
||||
|
||||
:rpc-rlimit-config (fs/path "resources/rlimit.edn")
|
||||
:rpc-climit-config (fs/path "resources/climit.edn")
|
||||
@@ -126,6 +125,7 @@
|
||||
(s/def ::database-max-pool-size ::us/integer)
|
||||
|
||||
(s/def ::quotes-teams-per-profile ::us/integer)
|
||||
(s/def ::quotes-access-tokens-per-profile ::us/integer)
|
||||
(s/def ::quotes-projects-per-team ::us/integer)
|
||||
(s/def ::quotes-invitations-per-team ::us/integer)
|
||||
(s/def ::quotes-profiles-per-team ::us/integer)
|
||||
@@ -146,16 +146,18 @@
|
||||
(s/def ::google-client-id ::us/string)
|
||||
(s/def ::google-client-secret ::us/string)
|
||||
(s/def ::oidc-client-id ::us/string)
|
||||
(s/def ::oidc-user-info-source ::us/keyword)
|
||||
(s/def ::oidc-client-secret ::us/string)
|
||||
(s/def ::oidc-base-uri ::us/string)
|
||||
(s/def ::oidc-token-uri ::us/string)
|
||||
(s/def ::oidc-auth-uri ::us/string)
|
||||
(s/def ::oidc-user-uri ::us/string)
|
||||
(s/def ::oidc-jwks-uri ::us/string)
|
||||
(s/def ::oidc-scopes ::us/set-of-strings)
|
||||
(s/def ::oidc-roles ::us/set-of-strings)
|
||||
(s/def ::oidc-roles-attr ::us/keyword)
|
||||
(s/def ::oidc-email-attr ::us/keyword)
|
||||
(s/def ::oidc-name-attr ::us/keyword)
|
||||
(s/def ::oidc-roles-attr ::us/string)
|
||||
(s/def ::oidc-email-attr ::us/string)
|
||||
(s/def ::oidc-name-attr ::us/string)
|
||||
(s/def ::host ::us/string)
|
||||
(s/def ::http-server-port ::us/integer)
|
||||
(s/def ::http-server-host ::us/string)
|
||||
@@ -174,8 +176,6 @@
|
||||
(s/def ::ldap-ssl ::us/boolean)
|
||||
(s/def ::ldap-starttls ::us/boolean)
|
||||
(s/def ::ldap-user-query ::us/string)
|
||||
(s/def ::loggers-loki-uri ::us/string)
|
||||
(s/def ::loggers-zmq-uri ::us/string)
|
||||
(s/def ::media-directory ::us/string)
|
||||
(s/def ::media-uri ::us/string)
|
||||
(s/def ::profile-bounce-max-age ::dt/duration)
|
||||
@@ -243,10 +243,12 @@
|
||||
::google-client-secret
|
||||
::oidc-client-id
|
||||
::oidc-client-secret
|
||||
::oidc-user-info-source
|
||||
::oidc-base-uri
|
||||
::oidc-token-uri
|
||||
::oidc-auth-uri
|
||||
::oidc-user-uri
|
||||
::oidc-jwks-uri
|
||||
::oidc-scopes
|
||||
::oidc-roles-attr
|
||||
::oidc-email-attr
|
||||
@@ -271,8 +273,6 @@
|
||||
::ldap-starttls
|
||||
::ldap-user-query
|
||||
::local-assets-uri
|
||||
::loggers-loki-uri
|
||||
::loggers-zmq-uri
|
||||
::media-max-file-size
|
||||
::profile-bounce-max-age
|
||||
::profile-bounce-threshold
|
||||
@@ -281,6 +281,7 @@
|
||||
::public-uri
|
||||
|
||||
::quotes-teams-per-profile
|
||||
::quotes-access-tokens-per-profile
|
||||
::quotes-projects-per-team
|
||||
::quotes-invitations-per-team
|
||||
::quotes-profiles-per-team
|
||||
@@ -326,6 +327,7 @@
|
||||
|
||||
(def default-flags
|
||||
[:enable-backend-api-doc
|
||||
:enable-backend-openapi-doc
|
||||
:enable-backend-worker
|
||||
:enable-secure-session-cookies
|
||||
:enable-email-verification])
|
||||
@@ -355,7 +357,7 @@
|
||||
(merge defaults)
|
||||
(us/conform ::config))
|
||||
(catch Throwable e
|
||||
(when (ex/ex-info? e)
|
||||
(when (ex/error? e)
|
||||
(println ";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;")
|
||||
(println "Error on validating configuration:")
|
||||
(println (some-> e ex-data ex/explain))
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
[app.db.sql :as sql]
|
||||
[app.metrics :as mtx]
|
||||
[app.util.json :as json]
|
||||
[app.util.migrations :as mg]
|
||||
[app.util.time :as dt]
|
||||
[clojure.java.io :as io]
|
||||
[clojure.spec.alpha :as s]
|
||||
@@ -32,7 +31,6 @@
|
||||
io.whitfin.siphash.SipHasherContainer
|
||||
java.io.InputStream
|
||||
java.io.OutputStream
|
||||
java.lang.AutoCloseable
|
||||
java.sql.Connection
|
||||
java.sql.Savepoint
|
||||
org.postgresql.PGConnection
|
||||
@@ -50,12 +48,9 @@
|
||||
;; Initialization
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(declare apply-migrations!)
|
||||
|
||||
(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 ::uri ::us/not-empty-string)
|
||||
@@ -64,26 +59,26 @@
|
||||
(s/def ::read-only? ::us/boolean)
|
||||
|
||||
(s/def ::pool-options
|
||||
(s/keys :opt-un [::uri ::name
|
||||
::min-size
|
||||
::max-size
|
||||
::connection-timeout
|
||||
::validation-timeout
|
||||
::migrations
|
||||
::username
|
||||
::password
|
||||
::mtx/metrics
|
||||
::read-only?]))
|
||||
(s/keys :opt [::uri
|
||||
::name
|
||||
::min-size
|
||||
::max-size
|
||||
::connection-timeout
|
||||
::validation-timeout
|
||||
::username
|
||||
::password
|
||||
::mtx/metrics
|
||||
::read-only?]))
|
||||
|
||||
(def defaults
|
||||
{:name :main
|
||||
:min-size 0
|
||||
:max-size 60
|
||||
:connection-timeout 10000
|
||||
:validation-timeout 10000
|
||||
:idle-timeout 120000 ; 2min
|
||||
:max-lifetime 1800000 ; 30m
|
||||
:read-only? false})
|
||||
{::name :main
|
||||
::min-size 0
|
||||
::max-size 60
|
||||
::connection-timeout 10000
|
||||
::validation-timeout 10000
|
||||
::idle-timeout 120000 ; 2min
|
||||
::max-lifetime 1800000 ; 30m
|
||||
::read-only? false})
|
||||
|
||||
(defmethod ig/prep-key ::pool
|
||||
[_ cfg]
|
||||
@@ -93,39 +88,23 @@
|
||||
(defmethod ig/pre-init-spec ::pool [_] ::pool-options)
|
||||
|
||||
(defmethod ig/init-key ::pool
|
||||
[_ {:keys [migrations read-only? uri] :as cfg}]
|
||||
(if uri
|
||||
(let [pool (create-pool cfg)]
|
||||
(l/info :hint "initialize connection pool"
|
||||
:name (d/name (:name cfg))
|
||||
:uri uri
|
||||
:read-only read-only?
|
||||
:with-credentials (and (contains? cfg :username)
|
||||
(contains? cfg :password))
|
||||
:min-size (:min-size cfg)
|
||||
:max-size (:max-size cfg))
|
||||
(when-not read-only?
|
||||
(some->> (seq migrations) (apply-migrations! pool)))
|
||||
pool)
|
||||
|
||||
(do
|
||||
(l/warn :hint "unable to initialize pool, missing url"
|
||||
:name (d/name (:name cfg))
|
||||
:read-only read-only?)
|
||||
nil)))
|
||||
[_ {:keys [::uri ::read-only?] :as cfg}]
|
||||
(when uri
|
||||
(l/info :hint "initialize connection pool"
|
||||
:name (d/name (::name cfg))
|
||||
:uri uri
|
||||
:read-only read-only?
|
||||
:with-credentials (and (contains? cfg ::username)
|
||||
(contains? cfg ::password))
|
||||
:min-size (::min-size cfg)
|
||||
:max-size (::max-size cfg))
|
||||
(create-pool cfg)))
|
||||
|
||||
(defmethod ig/halt-key! ::pool
|
||||
[_ pool]
|
||||
(when pool
|
||||
(.close ^HikariDataSource pool)))
|
||||
|
||||
(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
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
@@ -135,19 +114,19 @@
|
||||
"SET idle_in_transaction_session_timeout = 300000;"))
|
||||
|
||||
(defn- create-datasource-config
|
||||
[{:keys [metrics uri] :as cfg}]
|
||||
[{:keys [::mtx/metrics ::uri] :as cfg}]
|
||||
(let [config (HikariConfig.)]
|
||||
(doto config
|
||||
(.setJdbcUrl (str "jdbc:" uri))
|
||||
(.setPoolName (d/name (:name cfg)))
|
||||
(.setPoolName (d/name (::name cfg)))
|
||||
(.setAutoCommit true)
|
||||
(.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))
|
||||
(.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))
|
||||
|
||||
@@ -157,8 +136,8 @@
|
||||
(PrometheusMetricsTrackerFactory.)
|
||||
(.setMetricsTrackerFactory config)))
|
||||
|
||||
(some->> ^String (:username cfg) (.setUsername config))
|
||||
(some->> ^String (:password cfg) (.setPassword config))
|
||||
(some->> ^String (::username cfg) (.setUsername config))
|
||||
(some->> ^String (::password cfg) (.setPassword config))
|
||||
|
||||
config))
|
||||
|
||||
@@ -166,11 +145,9 @@
|
||||
[v]
|
||||
(instance? javax.sql.DataSource v))
|
||||
|
||||
(s/def ::pool pool?)
|
||||
(s/def ::conn some?)
|
||||
|
||||
;; DEPRECATED: to be removed in 1.18
|
||||
(s/def ::conn-or-pool some?)
|
||||
(s/def ::nilable-pool (s/nilable ::pool))
|
||||
(s/def ::pool pool?)
|
||||
(s/def ::pool-or-conn some?)
|
||||
|
||||
(defn closed?
|
||||
@@ -178,8 +155,18 @@
|
||||
(.isClosed ^HikariDataSource pool))
|
||||
|
||||
(defn read-only?
|
||||
[pool]
|
||||
(.isReadOnly ^HikariDataSource pool))
|
||||
[pool-or-conn]
|
||||
(cond
|
||||
(instance? HikariDataSource pool-or-conn)
|
||||
(.isReadOnly ^HikariDataSource pool-or-conn)
|
||||
|
||||
(instance? Connection pool-or-conn)
|
||||
(.isReadOnly ^Connection pool-or-conn)
|
||||
|
||||
:else
|
||||
(ex/raise :type :internal
|
||||
:code :invalid-connection
|
||||
:hint "invalid connection provided")))
|
||||
|
||||
(defn create-pool
|
||||
[cfg]
|
||||
@@ -237,44 +224,46 @@
|
||||
[pool]
|
||||
(jdbc/get-connection pool))
|
||||
|
||||
(def ^:private default-opts
|
||||
{:builder-fn sql/as-kebab-maps})
|
||||
|
||||
(defn exec!
|
||||
([ds sv]
|
||||
(exec! ds sv {}))
|
||||
(jdbc/execute! ds sv default-opts))
|
||||
([ds sv opts]
|
||||
(jdbc/execute! ds sv (assoc opts :builder-fn sql/as-kebab-maps))))
|
||||
(jdbc/execute! ds sv (merge default-opts opts))))
|
||||
|
||||
(defn exec-one!
|
||||
([ds sv] (exec-one! ds sv {}))
|
||||
([ds sv]
|
||||
(jdbc/execute-one! ds sv default-opts))
|
||||
([ds sv opts]
|
||||
(jdbc/execute-one! ds sv (assoc opts :builder-fn sql/as-kebab-maps))))
|
||||
(jdbc/execute-one! ds sv
|
||||
(-> (merge default-opts opts)
|
||||
(assoc :return-keys (::return-keys? opts false))))))
|
||||
|
||||
(defn insert!
|
||||
([ds table params] (insert! ds table params nil))
|
||||
([ds table params opts]
|
||||
(exec-one! ds
|
||||
(sql/insert table params opts)
|
||||
(merge {:return-keys true} opts))))
|
||||
[ds table params & {:as opts}]
|
||||
(exec-one! ds
|
||||
(sql/insert table params opts)
|
||||
(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)
|
||||
(merge {:return-keys true} opts))))
|
||||
[ds table cols rows & {:as opts}]
|
||||
(exec! ds
|
||||
(sql/insert-multi table cols rows opts)
|
||||
(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)
|
||||
(merge {:return-keys true} opts))))
|
||||
[ds table params where & {:as opts}]
|
||||
(exec-one! ds
|
||||
(sql/update table params where opts)
|
||||
(merge {::return-keys? true} opts)))
|
||||
|
||||
(defn delete!
|
||||
([ds table params] (delete! ds table params nil))
|
||||
([ds table params opts]
|
||||
(exec-one! ds
|
||||
(sql/delete table params opts)
|
||||
(assoc opts :return-keys true))))
|
||||
[ds table params & {:as opts}]
|
||||
(exec-one! ds
|
||||
(sql/delete table params opts)
|
||||
(merge {::return-keys? true} opts)))
|
||||
|
||||
(defn is-row-deleted?
|
||||
[{:keys [deleted-at]}]
|
||||
@@ -283,56 +272,34 @@
|
||||
(inst-ms (dt/now)))))
|
||||
|
||||
(defn get*
|
||||
"Internal function for retrieve a single row from database that
|
||||
matches a simple filters."
|
||||
([ds table params]
|
||||
(get* ds table params nil))
|
||||
([ds table params {:keys [check-deleted?] :or {check-deleted? true} :as opts}]
|
||||
(let [rows (exec! ds (sql/select table params opts))
|
||||
rows (cond->> rows
|
||||
check-deleted?
|
||||
(remove is-row-deleted?))]
|
||||
(first rows))))
|
||||
"Retrieve a single row from database that matches a simple filters. Do
|
||||
not raises exceptions."
|
||||
[ds table params & {:as opts}]
|
||||
(let [rows (exec! ds (sql/select table params opts))
|
||||
rows (cond->> rows
|
||||
(::remove-deleted? opts true)
|
||||
(remove is-row-deleted?))]
|
||||
(first rows)))
|
||||
|
||||
(defn get
|
||||
([ds table params]
|
||||
(get ds table params nil))
|
||||
([ds table params {:keys [check-deleted?] :or {check-deleted? true} :as opts}]
|
||||
(let [row (get* ds table params opts)]
|
||||
(when (and (not row) check-deleted?)
|
||||
(ex/raise :type :not-found
|
||||
:code :object-not-found
|
||||
:table table
|
||||
:hint "database object not found"))
|
||||
row)))
|
||||
|
||||
(defn get-by-params
|
||||
"DEPRECATED"
|
||||
([ds table params]
|
||||
(get-by-params ds table params nil))
|
||||
([ds table params {:keys [check-not-found] :or {check-not-found true} :as opts}]
|
||||
(let [row (get* ds table params (assoc opts :check-deleted? check-not-found))]
|
||||
(when (and (not row) check-not-found)
|
||||
(ex/raise :type :not-found
|
||||
:code :object-not-found
|
||||
:table table
|
||||
:hint "database object not found"))
|
||||
row)))
|
||||
"Retrieve a single row from database that matches a simple
|
||||
filters. Raises :not-found exception if no object is found."
|
||||
[ds table params & {:as opts}]
|
||||
(let [row (get* ds table params opts)]
|
||||
(when (and (not row) (::check-deleted? opts true))
|
||||
(ex/raise :type :not-found
|
||||
:code :object-not-found
|
||||
:table table
|
||||
:hint "database object not found"))
|
||||
row))
|
||||
|
||||
(defn get-by-id
|
||||
([ds table id]
|
||||
(get ds table {:id id} nil))
|
||||
([ds table id opts]
|
||||
(let [opts (cond-> opts
|
||||
(contains? opts :check-not-found)
|
||||
(assoc :check-deleted? (:check-not-found opts)))]
|
||||
(get ds table {:id id} opts))))
|
||||
[ds table id & {:as opts}]
|
||||
(get ds table {:id id} opts))
|
||||
|
||||
(defn query
|
||||
([ds table params]
|
||||
(query ds table params nil))
|
||||
([ds table params opts]
|
||||
(exec! ds (sql/select table params opts))))
|
||||
[ds table params & {:as opts}]
|
||||
(exec! ds (sql/select table params opts)))
|
||||
|
||||
(defn pgobject?
|
||||
([v]
|
||||
@@ -394,12 +361,20 @@
|
||||
[data]
|
||||
(org.postgresql.util.PGInterval. ^String data))
|
||||
|
||||
(defn connection?
|
||||
[conn]
|
||||
(instance? Connection conn))
|
||||
|
||||
(defn savepoint
|
||||
([^Connection conn]
|
||||
(.setSavepoint conn))
|
||||
([^Connection conn label]
|
||||
(.setSavepoint conn (name label))))
|
||||
|
||||
(defn release!
|
||||
[^Connection conn ^Savepoint sp ]
|
||||
(.releaseSavepoint conn sp))
|
||||
|
||||
(defn rollback!
|
||||
([^Connection conn]
|
||||
(.rollback conn))
|
||||
@@ -475,6 +450,11 @@
|
||||
(.setType "jsonb")
|
||||
(.setValue (json/encode-str data)))))
|
||||
|
||||
(defn get-update-count
|
||||
[result]
|
||||
(:next.jdbc/update-count result))
|
||||
|
||||
|
||||
;; --- Locks
|
||||
|
||||
(def ^:private siphash-state
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
(ns app.db.sql
|
||||
(:refer-clojure :exclude [update])
|
||||
(:require
|
||||
[app.db :as-alias db]
|
||||
[clojure.string :as str]
|
||||
[next.jdbc.optional :as jdbc-opt]
|
||||
[next.jdbc.sql.builder :as sql]))
|
||||
@@ -43,8 +44,10 @@
|
||||
([table where-params opts]
|
||||
(let [opts (merge default-opts opts)
|
||||
opts (cond-> opts
|
||||
(:for-update opts) (assoc :suffix "FOR UPDATE")
|
||||
(:for-key-share opts) (assoc :suffix "FOR KEY SHARE"))]
|
||||
(::db/for-update? opts) (assoc :suffix "FOR UPDATE")
|
||||
(::db/for-share? opts) (assoc :suffix "FOR KEY SHARE")
|
||||
(:for-update opts) (assoc :suffix "FOR UPDATE")
|
||||
(:for-key-share opts) (assoc :suffix "FOR KEY SHARE"))]
|
||||
(sql/for-query table where-params opts))))
|
||||
|
||||
(defn update
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC
|
||||
|
||||
(ns app.emails
|
||||
(ns app.email
|
||||
"Main api for send emails."
|
||||
(:require
|
||||
[app.common.exceptions :as ex]
|
||||
@@ -14,7 +14,7 @@
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.db.sql :as sql]
|
||||
[app.emails.invite-to-team :as-alias emails.invite-to-team]
|
||||
[app.email.invite-to-team :as-alias email.invite-to-team]
|
||||
[app.metrics :as mtx]
|
||||
[app.util.template :as tmpl]
|
||||
[app.worker :as wrk]
|
||||
@@ -37,6 +37,7 @@
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn- parse-address
|
||||
^"[Ljakarta.mail.internet.InternetAddress;"
|
||||
[v]
|
||||
(InternetAddress/parse ^String v))
|
||||
|
||||
@@ -64,14 +65,14 @@
|
||||
(some? bcc) (assign-recipient :bcc bcc)))
|
||||
|
||||
(defn- assign-from
|
||||
[mmsg {:keys [default-from]} {:keys [from] :as props}]
|
||||
[mmsg {:keys [::default-from] :as cfg} {:keys [from] :as params}]
|
||||
(let [from (or from default-from)]
|
||||
(when from
|
||||
(let [from (parse-address from)]
|
||||
(.addFrom ^MimeMessage mmsg from)))))
|
||||
|
||||
(defn- assign-reply-to
|
||||
[mmsg {:keys [default-reply-to] :as cfg} {:keys [reply-to] :as params}]
|
||||
[mmsg {:keys [::default-reply-to] :as cfg} {:keys [reply-to] :as params}]
|
||||
(let [reply-to (or reply-to default-reply-to)]
|
||||
(when reply-to
|
||||
(let [reply-to (parse-address reply-to)]
|
||||
@@ -127,9 +128,8 @@
|
||||
mmsg))
|
||||
|
||||
(defn- opts->props
|
||||
[{:keys [username tls host port timeout default-from]
|
||||
:or {timeout 30000}
|
||||
:as opts}]
|
||||
[{:keys [::username ::tls ::host ::port ::timeout ::default-from]
|
||||
:or {timeout 30000}}]
|
||||
(reduce-kv
|
||||
(fn [^Properties props k v]
|
||||
(if (nil? v)
|
||||
@@ -150,8 +150,9 @@
|
||||
"mail.smtp.connectiontimeout" timeout}))
|
||||
|
||||
(defn- create-smtp-session
|
||||
[opts]
|
||||
(let [props (opts->props opts)]
|
||||
^Session
|
||||
[cfg]
|
||||
(let [props (opts->props cfg)]
|
||||
(Session/getInstance props)))
|
||||
|
||||
(defn- create-smtp-message
|
||||
@@ -171,7 +172,7 @@
|
||||
;; TEMPLATE EMAIL IMPL
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(def ^:private email-path "app/emails/%(id)s/%(lang)s.%(type)s")
|
||||
(def ^:private email-path "app/email/%(id)s/%(lang)s.%(type)s")
|
||||
|
||||
(defn- render-email-template-part
|
||||
[type id context]
|
||||
@@ -283,14 +284,14 @@
|
||||
(s/def ::default-from ::cf/smtp-default-from)
|
||||
|
||||
(s/def ::smtp-config
|
||||
(s/keys :opt-un [::username
|
||||
::password
|
||||
::tls
|
||||
::ssl
|
||||
::host
|
||||
::port
|
||||
::default-from
|
||||
::default-reply-to]))
|
||||
(s/keys :opt [::username
|
||||
::password
|
||||
::tls
|
||||
::ssl
|
||||
::host
|
||||
::port
|
||||
::default-from
|
||||
::default-reply-to]))
|
||||
|
||||
(declare send-to-logger!)
|
||||
|
||||
@@ -304,10 +305,10 @@
|
||||
(fn [params]
|
||||
(when (contains? cf/flags :smtp)
|
||||
(let [session (create-smtp-session cfg)]
|
||||
(with-open [transport (.getTransport session (if (:ssl cfg) "smtps" "smtp"))]
|
||||
(with-open [transport (.getTransport session (if (::ssl cfg) "smtps" "smtp"))]
|
||||
(.connect ^Transport transport
|
||||
^String (:username cfg)
|
||||
^String (:password cfg))
|
||||
^String (::username cfg)
|
||||
^String (::password cfg))
|
||||
|
||||
(let [^MimeMessage message (create-smtp-message cfg session params)]
|
||||
(.sendMessage ^Transport transport
|
||||
@@ -319,10 +320,10 @@
|
||||
(send-to-logger! cfg params))))
|
||||
|
||||
(defmethod ig/pre-init-spec ::handler [_]
|
||||
(s/keys :req-un [::sendmail ::mtx/metrics]))
|
||||
(s/keys :req [::sendmail ::mtx/metrics]))
|
||||
|
||||
(defmethod ig/init-key ::handler
|
||||
[_ {:keys [sendmail]}]
|
||||
[_ {:keys [::sendmail]}]
|
||||
(fn [{:keys [props] :as task}]
|
||||
(sendmail props)))
|
||||
|
||||
@@ -340,7 +341,7 @@
|
||||
(map :content)
|
||||
first)))
|
||||
(println "******** end email" (:id email) "**********"))]
|
||||
(l/info ::l/raw out)))
|
||||
(l/raw! :info out)))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; EMAIL FACTORIES
|
||||
@@ -380,14 +381,14 @@
|
||||
"Password change confirmation email"
|
||||
(template-factory ::change-email))
|
||||
|
||||
(s/def ::emails.invite-to-team/invited-by ::us/string)
|
||||
(s/def ::emails.invite-to-team/team ::us/string)
|
||||
(s/def ::emails.invite-to-team/token ::us/string)
|
||||
(s/def ::email.invite-to-team/invited-by ::us/string)
|
||||
(s/def ::email.invite-to-team/team ::us/string)
|
||||
(s/def ::email.invite-to-team/token ::us/string)
|
||||
|
||||
(s/def ::invite-to-team
|
||||
(s/keys :req-un [::emails.invite-to-team/invited-by
|
||||
::emails.invite-to-team/token
|
||||
::emails.invite-to-team/team]))
|
||||
(s/keys :req-un [::email.invite-to-team/invited-by
|
||||
::email.invite-to-team/token
|
||||
::email.invite-to-team/team]))
|
||||
|
||||
(def invite-to-team
|
||||
"Teams member invitation email."
|
||||
@@ -6,23 +6,34 @@
|
||||
|
||||
(ns app.http
|
||||
(:require
|
||||
[app.auth.oidc :as-alias oidc]
|
||||
[app.common.data :as d]
|
||||
[app.common.logging :as l]
|
||||
[app.common.transit :as t]
|
||||
[app.db :as-alias db]
|
||||
[app.http.access-token :as actoken]
|
||||
[app.http.assets :as-alias assets]
|
||||
[app.http.awsns :as-alias awsns]
|
||||
[app.http.debug :as-alias debug]
|
||||
[app.http.errors :as errors]
|
||||
[app.http.middleware :as mw]
|
||||
[app.http.session :as session]
|
||||
[app.http.websocket :as-alias ws]
|
||||
[app.main :as-alias main]
|
||||
[app.metrics :as mtx]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.doc :as-alias rpc.doc]
|
||||
[app.worker :as wrk]
|
||||
[clojure.spec.alpha :as s]
|
||||
[integrant.core :as ig]
|
||||
[promesa.exec :as px]
|
||||
[reitit.core :as r]
|
||||
[reitit.middleware :as rr]
|
||||
[yetti.adapter :as yt]
|
||||
[yetti.request :as yrq]
|
||||
[yetti.response :as yrs]))
|
||||
[yetti.response :as-alias yrs]))
|
||||
|
||||
(declare wrap-router)
|
||||
(declare router-handler)
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; HTTP SERVER
|
||||
@@ -37,140 +48,131 @@
|
||||
(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"
|
||||
:port 6060
|
||||
:host "0.0.0.0"
|
||||
:max-body-size (* 1024 1024 30) ; 30 MiB
|
||||
:max-multipart-body-size (* 1024 1024 120)} ; 120 MiB
|
||||
(merge {::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)))))
|
||||
(s/keys :req [::port ::host]
|
||||
:opt [::max-body-size
|
||||
::max-multipart-body-size
|
||||
::router
|
||||
::handler
|
||||
::io-threads
|
||||
::wrk/executor]))
|
||||
|
||||
(defmethod ig/init-key ::server
|
||||
[_ {:keys [handler router port name host] :as cfg}]
|
||||
(l/info :hint "starting http server" :port port :host host :name name)
|
||||
[_ {:keys [::handler ::router ::host ::port] :as cfg}]
|
||||
(l/info :hint "starting http server" :port port :host host)
|
||||
(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)
|
||||
:http/max-body-size (::max-body-size cfg)
|
||||
:http/max-multipart-body-size (::max-multipart-body-size cfg)
|
||||
:xnio/io-threads (or (::io-threads cfg)
|
||||
(max 3 (px/get-available-processors)))
|
||||
:xnio/worker-threads (or (::worker-threads cfg)
|
||||
(max 6 (px/get-available-processors)))
|
||||
:xnio/dispatch true
|
||||
:socket/backlog 4069
|
||||
:ring/async true}
|
||||
|
||||
handler (if (some? router)
|
||||
(wrap-router router)
|
||||
handler (cond
|
||||
(some? router)
|
||||
(router-handler router)
|
||||
|
||||
handler)
|
||||
server (yt/server handler (d/without-nils options))]
|
||||
(assoc cfg :server (yt/start! server))))
|
||||
(some? handler)
|
||||
handler
|
||||
|
||||
:else
|
||||
(throw (UnsupportedOperationException. "handler or router are required")))
|
||||
|
||||
options (d/without-nils options)
|
||||
server (yt/server handler options)]
|
||||
|
||||
(assoc cfg ::server (yt/start! server))))
|
||||
|
||||
(defmethod ig/halt-key! ::server
|
||||
[_ {:keys [server name port] :as cfg}]
|
||||
(l/info :msg "stopping http server" :name name :port port)
|
||||
[_ {:keys [::server ::port] :as cfg}]
|
||||
(l/info :msg "stopping http server" :port port)
|
||||
(yt/stop! server))
|
||||
|
||||
(defn- not-found-handler
|
||||
[_ respond _]
|
||||
(respond (yrs/response 404)))
|
||||
(respond {::yrs/status 404}))
|
||||
|
||||
(defn- wrap-router
|
||||
(defn- router-handler
|
||||
[router]
|
||||
(letfn [(handler [request respond raise]
|
||||
(letfn [(resolve-handler [request]
|
||||
(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 (assoc request :path-params params)]
|
||||
(handler request respond raise))
|
||||
(not-found-handler request respond raise)))
|
||||
(partial handler request))
|
||||
(partial not-found-handler request)))
|
||||
|
||||
(on-error [cause request respond]
|
||||
(on-error [cause request]
|
||||
(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})))))))]
|
||||
(cond-> response
|
||||
(map? body)
|
||||
(-> (update ::yrs/headers assoc "content-type" "application/transit+json")
|
||||
(assoc ::yrs/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))))))
|
||||
(let [handler (resolve-handler request)
|
||||
exchange (yrq/exchange request)]
|
||||
(handler
|
||||
(fn [response]
|
||||
(yt/dispatch! exchange (partial respond response)))
|
||||
(fn [cause]
|
||||
(let [response (on-error cause request)]
|
||||
(yt/dispatch! exchange (partial respond response)))))))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; HTTP ROUTER
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(s/def ::assets map?)
|
||||
(s/def ::awsns-handler fn?)
|
||||
(s/def ::debug-routes (s/nilable vector?))
|
||||
(s/def ::doc-routes (s/nilable vector?))
|
||||
(s/def ::feedback fn?)
|
||||
(s/def ::oauth map?)
|
||||
(s/def ::oidc-routes (s/nilable vector?))
|
||||
(s/def ::rpc-routes (s/nilable vector?))
|
||||
(s/def ::session ::session/session)
|
||||
(s/def ::storage map?)
|
||||
(s/def ::ws fn?)
|
||||
|
||||
(defmethod ig/pre-init-spec ::router [_]
|
||||
(s/keys :req-un [::mtx/metrics
|
||||
::ws
|
||||
::storage
|
||||
::assets
|
||||
::session
|
||||
::feedback
|
||||
::awsns-handler
|
||||
::debug-routes
|
||||
::oidc-routes
|
||||
::rpc-routes
|
||||
::doc-routes]))
|
||||
(s/keys :req [::session/manager
|
||||
::ws/routes
|
||||
::rpc/routes
|
||||
::rpc.doc/routes
|
||||
::oidc/routes
|
||||
::main/props
|
||||
::assets/routes
|
||||
::debug/routes
|
||||
::db/pool
|
||||
::mtx/routes
|
||||
::awsns/routes]))
|
||||
|
||||
(defmethod ig/init-key ::router
|
||||
[_ {:keys [ws session metrics assets feedback] :as cfg}]
|
||||
[_ cfg]
|
||||
(rr/router
|
||||
[["" {:middleware [[mw/server-timing]
|
||||
[mw/format-response]
|
||||
[mw/params]
|
||||
[mw/format-response]
|
||||
[mw/parse-request]
|
||||
[session/middleware-1 session]
|
||||
[session/soft-auth cfg]
|
||||
[actoken/soft-auth cfg]
|
||||
[mw/errors errors/handle]
|
||||
[mw/restrict-methods]]}
|
||||
[mw/restrict-methods]
|
||||
[mw/with-dispatch :vthread]]}
|
||||
|
||||
["/metrics" {:handler (::mtx/handler metrics)
|
||||
:allowed-methods #{:get}}]
|
||||
|
||||
["/assets" {:middleware [[session/middleware-2 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)}]]
|
||||
|
||||
(:debug-routes cfg)
|
||||
(::mtx/routes cfg)
|
||||
(::assets/routes cfg)
|
||||
(::debug/routes cfg)
|
||||
|
||||
["/webhooks"
|
||||
["/sns" {:handler (:awsns-handler cfg)
|
||||
:allowed-methods #{:post}}]]
|
||||
(::awsns/routes cfg)]
|
||||
|
||||
["/ws/notifications" {:middleware [[session/middleware-2 session]]
|
||||
:handler ws
|
||||
:allowed-methods #{:get}}]
|
||||
(::ws/routes cfg)
|
||||
|
||||
["/api" {:middleware [[mw/cors]
|
||||
[session/middleware-2 session]]}
|
||||
["/feedback" {:handler feedback
|
||||
:allowed-methods #{:post}}]
|
||||
(:doc-routes cfg)
|
||||
(:oidc-routes cfg)
|
||||
(:rpc-routes cfg)]]]))
|
||||
["/api" {:middleware [[mw/cors]]}
|
||||
(::oidc/routes cfg)
|
||||
(::rpc.doc/routes cfg)
|
||||
(::rpc/routes cfg)]]]))
|
||||
|
||||
84
backend/src/app/http/access_token.clj
Normal file
84
backend/src/app/http/access_token.clj
Normal file
@@ -0,0 +1,84 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC
|
||||
|
||||
(ns app.http.access-token
|
||||
(:require
|
||||
[app.common.logging :as l]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.main :as-alias main]
|
||||
[app.tokens :as tokens]
|
||||
[yetti.request :as yrq]))
|
||||
|
||||
(def header-re #"^Token\s+(.*)")
|
||||
|
||||
(defn- get-token
|
||||
[request]
|
||||
(some->> (yrq/get-header request "authorization")
|
||||
(re-matches header-re)
|
||||
(second)))
|
||||
|
||||
(defn- decode-token
|
||||
[props token]
|
||||
(when token
|
||||
(tokens/verify props {:token token :iss "access-token"})))
|
||||
|
||||
(def sql:get-token-data
|
||||
"SELECT perms, profile_id, expires_at
|
||||
FROM access_token
|
||||
WHERE id = ?
|
||||
AND (expires_at IS NULL
|
||||
OR (expires_at > now()));")
|
||||
|
||||
(defn- get-token-data
|
||||
[pool token-id]
|
||||
(when-not (db/read-only? pool)
|
||||
(some-> (db/exec-one! pool [sql:get-token-data token-id])
|
||||
(update :perms db/decode-pgarray #{}))))
|
||||
|
||||
(defn- wrap-soft-auth
|
||||
"Soft Authentication, will be executed synchronously on the undertow
|
||||
worker thread."
|
||||
[handler {:keys [::main/props]}]
|
||||
(letfn [(handle-request [request]
|
||||
(try
|
||||
(let [token (get-token request)
|
||||
claims (decode-token props token)]
|
||||
(cond-> request
|
||||
(map? claims)
|
||||
(assoc ::id (:tid claims))))
|
||||
(catch Throwable cause
|
||||
(l/trace :hint "exception on decoding malformed token" :cause cause)
|
||||
request)))]
|
||||
|
||||
(fn [request respond raise]
|
||||
(let [request (handle-request request)]
|
||||
(handler request respond raise)))))
|
||||
|
||||
(defn- wrap-authz
|
||||
"Authorization middleware, will be executed synchronously on vthread."
|
||||
[handler {:keys [::db/pool]}]
|
||||
(fn [request]
|
||||
(let [{:keys [perms profile-id expires-at]} (some->> (::id request) (get-token-data pool))]
|
||||
(handler (cond-> request
|
||||
(some? perms)
|
||||
(assoc ::perms perms)
|
||||
(some? profile-id)
|
||||
(assoc ::profile-id profile-id)
|
||||
(some? expires-at)
|
||||
(assoc ::expires-at expires-at))))))
|
||||
|
||||
(def soft-auth
|
||||
{:name ::soft-auth
|
||||
:compile (fn [& _]
|
||||
(when (contains? cf/flags :access-tokens)
|
||||
wrap-soft-auth))})
|
||||
|
||||
(def authz
|
||||
{:name ::authz
|
||||
:compile (fn [& _]
|
||||
(when (contains? cf/flags :access-tokens)
|
||||
wrap-authz))})
|
||||
@@ -7,19 +7,16 @@
|
||||
(ns app.http.assets
|
||||
"Assets related handlers."
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.spec :as us]
|
||||
[app.common.uri :as u]
|
||||
[app.db :as db]
|
||||
[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]
|
||||
[promesa.core :as p]
|
||||
[promesa.exec :as px]
|
||||
[yetti.response :as yrs]))
|
||||
[yetti.response :as-alias yrs]))
|
||||
|
||||
(def ^:private cache-max-age
|
||||
(dt/duration {:hours 24}))
|
||||
@@ -27,105 +24,87 @@
|
||||
(def ^:private signature-max-age
|
||||
(dt/duration {:hours 24 :minutes 15}))
|
||||
|
||||
(defn coerce-id
|
||||
[id]
|
||||
(let [res (parse-uuid id)]
|
||||
(when-not (uuid? res)
|
||||
(defn get-id
|
||||
[{:keys [path-params]}]
|
||||
(or (some-> path-params :id d/parse-uuid)
|
||||
(ex/raise :type :not-found
|
||||
:hint "object not found"))
|
||||
res))
|
||||
:hunt "object not found")))
|
||||
|
||||
(defn- get-file-media-object
|
||||
[{: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)))
|
||||
[pool id]
|
||||
(db/get pool :file-media-object {:id id}))
|
||||
|
||||
(defn- serve-object-from-s3
|
||||
[{:keys [::sto/storage] :as cfg} obj]
|
||||
(let [{:keys [host port] :as url} (sto/get-object-url storage obj {:max-age signature-max-age})]
|
||||
{::yrs/status 307
|
||||
::yrs/headers {"location" (str url)
|
||||
"x-host" (cond-> host port (str ":" port))
|
||||
"x-mtype" (-> obj meta :content-type)
|
||||
"cache-control" (str "max-age=" (inst-ms cache-max-age))}}))
|
||||
|
||||
(defn- serve-object-from-fs
|
||||
[{:keys [::path]} obj]
|
||||
(let [purl (u/join (u/uri path)
|
||||
(sto/object->relative-path obj))
|
||||
mdata (meta obj)
|
||||
headers {"x-accel-redirect" (:path purl)
|
||||
"content-type" (:content-type mdata)
|
||||
"cache-control" (str "max-age=" (inst-ms cache-max-age))}]
|
||||
{::yrs/status 204
|
||||
::yrs/headers headers}))
|
||||
|
||||
(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)
|
||||
:s3
|
||||
(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))
|
||||
"x-mtype" (:content-type mdata)
|
||||
"cache-control" (str "max-age=" (inst-ms cache-max-age))}))
|
||||
|
||||
:fs
|
||||
(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))})))))
|
||||
[{:keys [::sto/storage] :as cfg} {:keys [backend] :as obj}]
|
||||
(let [backend (sto/resolve-backend storage backend)]
|
||||
(case (::sto/type backend)
|
||||
:s3 (serve-object-from-s3 cfg obj)
|
||||
:fs (serve-object-from-fs cfg obj))))
|
||||
|
||||
(defn objects-handler
|
||||
"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)))
|
||||
[{:keys [::sto/storage] :as cfg} request]
|
||||
(let [id (get-id request)
|
||||
obj (sto/get-object storage id)]
|
||||
(if obj
|
||||
(serve-object cfg obj)
|
||||
{::yrs/status 404})))
|
||||
|
||||
(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))))
|
||||
[{:keys [::sto/storage] :as cfg} request kf]
|
||||
(let [pool (::db/pool storage)
|
||||
id (get-id request)
|
||||
mobj (get-file-media-object pool id)
|
||||
sobj (sto/get-object storage (kf mobj))]
|
||||
(if sobj
|
||||
(serve-object cfg sobj)
|
||||
{::yrs/status 404})))
|
||||
|
||||
(defn file-objects-handler
|
||||
"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)))
|
||||
[cfg request]
|
||||
(generic-handler cfg request :media-id))
|
||||
|
||||
(defn file-thumbnails-handler
|
||||
"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)))
|
||||
[cfg request]
|
||||
(generic-handler cfg request #(or (:thumbnail-id %) (:media-id %))))
|
||||
|
||||
;; --- Initialization
|
||||
|
||||
(s/def ::storage some?)
|
||||
(s/def ::assets-path ::us/string)
|
||||
(s/def ::cache-max-age ::dt/duration)
|
||||
(s/def ::signature-max-age ::dt/duration)
|
||||
(s/def ::path ::us/string)
|
||||
(s/def ::routes vector?)
|
||||
|
||||
(defmethod ig/pre-init-spec ::handlers [_]
|
||||
(s/keys :req-un [::storage
|
||||
::wrk/executor
|
||||
::mtx/metrics
|
||||
::assets-path
|
||||
::cache-max-age
|
||||
::signature-max-age]))
|
||||
(defmethod ig/pre-init-spec ::routes [_]
|
||||
(s/keys :req [::sto/storage ::path]))
|
||||
|
||||
(defmethod ig/init-key ::handlers
|
||||
(defmethod ig/init-key ::routes
|
||||
[_ cfg]
|
||||
{:objects-handler (partial objects-handler cfg)
|
||||
:file-objects-handler (partial file-objects-handler cfg)
|
||||
:file-thumbnails-handler (partial file-thumbnails-handler cfg)})
|
||||
|
||||
["/assets"
|
||||
["/by-id/:id" {:handler (partial objects-handler cfg)}]
|
||||
["/by-file-media-id/:id" {:handler (partial file-objects-handler cfg)}]
|
||||
["/by-file-media-id/:id/thumbnail" {:handler (partial file-thumbnails-handler cfg)}]])
|
||||
|
||||
@@ -21,25 +21,27 @@
|
||||
[jsonista.core :as j]
|
||||
[promesa.exec :as px]
|
||||
[yetti.request :as yrq]
|
||||
[yetti.response :as yrs]))
|
||||
[yetti.response :as-alias yrs]))
|
||||
|
||||
(declare parse-json)
|
||||
(declare handle-request)
|
||||
(declare parse-notification)
|
||||
(declare process-report)
|
||||
|
||||
(defmethod ig/pre-init-spec ::handler [_]
|
||||
(defmethod ig/pre-init-spec ::routes [_]
|
||||
(s/keys :req [::http/client
|
||||
::main/props
|
||||
::db/pool
|
||||
::wrk/executor]))
|
||||
|
||||
(defmethod ig/init-key ::handler
|
||||
(defmethod ig/init-key ::routes
|
||||
[_ {:keys [::wrk/executor] :as cfg}]
|
||||
(fn [request respond _]
|
||||
(let [data (-> request yrq/body slurp)]
|
||||
(px/run! executor #(handle-request cfg data)))
|
||||
(respond (yrs/response 200))))
|
||||
(letfn [(handler [request]
|
||||
(let [data (-> request yrq/body slurp)]
|
||||
(px/run! executor #(handle-request cfg data)))
|
||||
{::yrs/status 200})]
|
||||
["/sns" {:handler handler
|
||||
:allowed-methods #{:post}}]))
|
||||
|
||||
(defn handle-request
|
||||
[cfg data]
|
||||
@@ -105,8 +107,7 @@
|
||||
[cfg headers]
|
||||
(let [tdata (get headers "x-penpot-data")]
|
||||
(when-not (str/empty? tdata)
|
||||
(let [sprops (::main/props cfg)
|
||||
result (tokens/verify sprops {:token tdata :iss :profile-identity})]
|
||||
(let [result (tokens/verify (::main/props cfg) {:token tdata :iss :profile-identity})]
|
||||
(:profile-id result)))))
|
||||
|
||||
(defn- parse-notification
|
||||
|
||||
@@ -40,12 +40,25 @@
|
||||
(catch Throwable cause
|
||||
(p/rejected cause))))))
|
||||
|
||||
(defn- resolve-client
|
||||
[params]
|
||||
(cond
|
||||
(instance? HttpClient params)
|
||||
params
|
||||
|
||||
(map? params)
|
||||
(resolve-client (::client params))
|
||||
|
||||
:else
|
||||
(throw (UnsupportedOperationException. "invalid arguments"))))
|
||||
|
||||
(defn req!
|
||||
"A convencience toplevel function for gradual migration to a new API
|
||||
convention."
|
||||
([{:keys [::client] :as holder} request]
|
||||
(us/assert! ::client-holder holder)
|
||||
(send! client request {}))
|
||||
([{:keys [::client] :as holder} request options]
|
||||
(us/assert! ::client-holder holder)
|
||||
(send! client request options)))
|
||||
([cfg-or-client request]
|
||||
(let [client (resolve-client cfg-or-client)]
|
||||
(send! client request {})))
|
||||
([cfg-or-client request options]
|
||||
(let [client (resolve-client cfg-or-client)]
|
||||
(send! client request options))))
|
||||
|
||||
|
||||
@@ -13,15 +13,14 @@
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.http.middleware :as mw]
|
||||
[app.http.session :as session]
|
||||
[app.rpc.commands.binfile :as binf]
|
||||
[app.rpc.commands.files.create :refer [create-file]]
|
||||
[app.rpc.queries.profile :as profile]
|
||||
[app.rpc.commands.files-create :refer [create-file]]
|
||||
[app.rpc.commands.profile :as profile]
|
||||
[app.storage :as-alias sto]
|
||||
[app.util.blob :as blob]
|
||||
[app.util.template :as tmpl]
|
||||
[app.util.time :as dt]
|
||||
[app.worker :as wrk]
|
||||
[clojure.spec.alpha :as s]
|
||||
[cuerdas.core :as str]
|
||||
[datoteka.io :as io]
|
||||
@@ -39,36 +38,40 @@
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn authorized?
|
||||
[pool {:keys [profile-id]}]
|
||||
[pool {:keys [::session/profile-id]}]
|
||||
(or (= "devenv" (cf/get :host))
|
||||
(let [profile (ex/ignoring (profile/retrieve-profile-data pool profile-id))
|
||||
(let [profile (ex/ignoring (profile/get-profile pool profile-id))
|
||||
admins (or (cf/get :admins) #{})]
|
||||
(contains? admins (:email profile)))))
|
||||
|
||||
(defn prepare-response
|
||||
[body]
|
||||
(let [headers {"content-type" "application/transit+json"}]
|
||||
(yrs/response :status 200 :body body :headers headers)))
|
||||
{::yrs/status 200
|
||||
::yrs/body body
|
||||
::yrs/headers headers}))
|
||||
|
||||
(defn prepare-download-response
|
||||
[body filename]
|
||||
(let [headers {"content-disposition" (str "attachment; filename=" filename)
|
||||
"content-type" "application/octet-stream"}]
|
||||
(yrs/response :status 200 :body body :headers headers)))
|
||||
{::yrs/status 200
|
||||
::yrs/body body
|
||||
::yrs/headers headers}))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; INDEX
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn index-handler
|
||||
[{:keys [pool]} request]
|
||||
[{:keys [::db/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 "app/templates/debug.tmpl")
|
||||
(tmpl/render {}))))
|
||||
{::yrs/status 200
|
||||
::yrs/headers {"content-type" "text/html"}
|
||||
::yrs/body (-> (io/resource "app/templates/debug.tmpl")
|
||||
(tmpl/render {}))})
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; FILE CHANGES
|
||||
@@ -81,7 +84,7 @@
|
||||
"select revn, changes, data from file_change where file_id=? and revn = ?")
|
||||
|
||||
(defn- retrieve-file-data
|
||||
[{:keys [pool]} {:keys [params profile-id] :as request}]
|
||||
[{:keys [::db/pool]} {:keys [params ::session/profile-id] :as request}]
|
||||
(when-not (authorized? pool request)
|
||||
(ex/raise :type :authentication
|
||||
:code :only-admins-allowed))
|
||||
@@ -107,17 +110,19 @@
|
||||
(prepare-download-response data filename)
|
||||
|
||||
(contains? params :clone)
|
||||
(let [project-id (some-> (profile/retrieve-additional-data pool profile-id) :default-project-id)
|
||||
data (some-> data blob/decode)]
|
||||
(let [profile (profile/get-profile pool profile-id)
|
||||
project-id (:default-project-id profile)
|
||||
data (blob/decode data)]
|
||||
(create-file pool {:id (uuid/next)
|
||||
:name (str "Cloned file: " filename)
|
||||
:project-id project-id
|
||||
:profile-id profile-id
|
||||
:data data})
|
||||
(yrs/response 201 "OK CREATED"))
|
||||
{::yrs/status 201
|
||||
::yrs/body "OK CREATED"})
|
||||
|
||||
:else
|
||||
(prepare-response (some-> data blob/decode))))))
|
||||
(prepare-response (blob/decode data))))))
|
||||
|
||||
(defn- is-file-exists?
|
||||
[pool id]
|
||||
@@ -125,8 +130,9 @@
|
||||
(-> (db/exec-one! pool [sql id]) :exists)))
|
||||
|
||||
(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)
|
||||
[{:keys [::db/pool]} {:keys [::session/profile-id params] :as request}]
|
||||
(let [profile (profile/get-profile pool profile-id)
|
||||
project-id (:default-project-id profile)
|
||||
data (some-> params :file :path io/read-as-bytes blob/decode)]
|
||||
|
||||
(if (and data project-id)
|
||||
@@ -141,7 +147,8 @@
|
||||
(db/update! pool :file
|
||||
{:data (blob/encode data)}
|
||||
{:id file-id})
|
||||
(yrs/response 200 "OK UPDATED"))
|
||||
{::yrs/status 200
|
||||
::yrs/body "OK UPDATED"})
|
||||
|
||||
(do
|
||||
(create-file pool {:id file-id
|
||||
@@ -149,9 +156,11 @@
|
||||
:project-id project-id
|
||||
:profile-id profile-id
|
||||
:data data})
|
||||
(yrs/response 201 "OK CREATED"))))
|
||||
{::yrs/status 201
|
||||
::yrs/body "OK CREATED"})))
|
||||
|
||||
(yrs/response 500 "ERROR"))))
|
||||
{::yrs/status 500
|
||||
::yrs/body "ERROR"})))
|
||||
|
||||
(defn file-data-handler
|
||||
[cfg request]
|
||||
@@ -162,7 +171,7 @@
|
||||
:code :method-not-found)))
|
||||
|
||||
(defn file-changes-handler
|
||||
[{:keys [pool]} {:keys [params] :as request}]
|
||||
[{:keys [::db/pool]} {:keys [params] :as request}]
|
||||
(when-not (authorized? pool request)
|
||||
(ex/raise :type :authentication
|
||||
:code :only-admins-allowed))
|
||||
@@ -202,46 +211,53 @@
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn error-handler
|
||||
[{:keys [pool]} request]
|
||||
(letfn [(parse-id [request]
|
||||
(let [id (get-in request [:path-params :id])
|
||||
id (parse-uuid id)]
|
||||
(when (uuid? id)
|
||||
id)))
|
||||
|
||||
(retrieve-report [id]
|
||||
[{:keys [::db/pool]} request]
|
||||
(letfn [(get-report [{:keys [path-params]}]
|
||||
(ex/ignoring
|
||||
(some-> (db/get-by-id pool :server-error-report id) :content db/decode-transit-pgobject)))
|
||||
(let [report-id (some-> path-params :id parse-uuid)]
|
||||
(some-> (db/get-by-id pool :server-error-report report-id)
|
||||
(update :content db/decode-transit-pgobject)))))
|
||||
|
||||
(render-template [report]
|
||||
(let [context (dissoc report
|
||||
(render-template-v1 [{:keys [content]}]
|
||||
(let [context (dissoc content
|
||||
:trace :cause :params :data :spec-problems :message
|
||||
:spec-explain :spec-value :error :explain :hint)
|
||||
params {:context (pp/pprint-str 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)}]
|
||||
:hint (:hint content)
|
||||
:spec-explain (:spec-explain content)
|
||||
:spec-problems (:spec-problems content)
|
||||
:spec-value (:spec-value content)
|
||||
:data (:data content)
|
||||
:trace (or (:trace content)
|
||||
(some-> content :error :trace))
|
||||
:params (:params content)}]
|
||||
(-> (io/resource "app/templates/error-report.tmpl")
|
||||
(tmpl/render params))))]
|
||||
(tmpl/render params))))
|
||||
|
||||
(render-template-v2 [{report :content}]
|
||||
(-> (io/resource "app/templates/error-report.v2.tmpl")
|
||||
(tmpl/render report)))
|
||||
|
||||
(render-template-v3 [{report :content}]
|
||||
(-> (io/resource "app/templates/error-report.v3.tmpl")
|
||||
(tmpl/render report)))
|
||||
]
|
||||
|
||||
(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")))))
|
||||
(if-let [report (get-report request)]
|
||||
(let [result (case (:version report)
|
||||
1 (render-template-v1 report)
|
||||
2 (render-template-v2 report)
|
||||
3 (render-template-v3 report))]
|
||||
{::yrs/status 200
|
||||
::yrs/body result
|
||||
::yrs/headers {"content-type" "text/html; charset=utf-8"
|
||||
"x-robots-tag" "noindex"}})
|
||||
{::yrs/status 404
|
||||
::yrs/body "not found"})))
|
||||
|
||||
(def sql:error-reports
|
||||
"SELECT id, created_at,
|
||||
@@ -251,24 +267,24 @@
|
||||
LIMIT 100")
|
||||
|
||||
(defn error-list-handler
|
||||
[{:keys [pool]} request]
|
||||
[{:keys [::db/pool]} request]
|
||||
(when-not (authorized? pool request)
|
||||
(ex/raise :type :authentication
|
||||
:code :only-admins-allowed))
|
||||
(let [items (->> (db/exec! pool [sql:error-reports])
|
||||
(map #(update % :created-at dt/format-instant :rfc1123)))]
|
||||
(yrs/response :status 200
|
||||
:body (-> (io/resource "app/templates/error-list.tmpl")
|
||||
(tmpl/render {:items items}))
|
||||
:headers {"content-type" "text/html; charset=utf-8"
|
||||
"x-robots-tag" "noindex"})))
|
||||
{::yrs/status 200
|
||||
::yrs/body (-> (io/resource "app/templates/error-list.tmpl")
|
||||
(tmpl/render {:items items}))
|
||||
::yrs/headers {"content-type" "text/html; charset=utf-8"
|
||||
"x-robots-tag" "noindex"}}))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; EXPORT/IMPORT
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn export-handler
|
||||
[{:keys [pool] :as cfg} {:keys [params profile-id] :as request}]
|
||||
[{:keys [::db/pool] :as cfg} {:keys [params ::session/profile-id] :as request}]
|
||||
|
||||
(let [file-ids (->> (:file-ids params)
|
||||
(remove empty?)
|
||||
@@ -287,7 +303,8 @@
|
||||
(assoc ::binf/include-libraries? libs?)
|
||||
(binf/export-to-tmpfile!))]
|
||||
(if clone?
|
||||
(let [project-id (some-> (profile/retrieve-additional-data pool profile-id) :default-project-id)]
|
||||
(let [profile (profile/get-profile pool profile-id)
|
||||
project-id (:default-project-id profile)]
|
||||
(binf/import!
|
||||
(assoc cfg
|
||||
::binf/input path
|
||||
@@ -296,28 +313,28 @@
|
||||
::binf/profile-id profile-id
|
||||
::binf/project-id project-id))
|
||||
|
||||
(yrs/response
|
||||
:status 200
|
||||
:headers {"content-type" "text/plain"}
|
||||
:body "OK CLONED"))
|
||||
{::yrs/status 200
|
||||
::yrs/headers {"content-type" "text/plain"}
|
||||
::yrs/body "OK CLONED"})
|
||||
|
||||
{::yrs/status 200
|
||||
::yrs/body (io/input-stream path)
|
||||
::yrs/headers {"content-type" "application/octet-stream"
|
||||
"content-disposition" (str "attachmen; filename=" (first file-ids) ".penpot")}}))))
|
||||
|
||||
(yrs/response
|
||||
:status 200
|
||||
:headers {"content-type" "application/octet-stream"
|
||||
"content-disposition" (str "attachmen; filename=" (first file-ids) ".penpot")}
|
||||
:body (io/input-stream path))))))
|
||||
|
||||
|
||||
(defn import-handler
|
||||
[{:keys [pool] :as cfg} {:keys [params profile-id] :as request}]
|
||||
[{:keys [::db/pool] :as cfg} {:keys [params ::session/profile-id] :as request}]
|
||||
(when-not (contains? params :file)
|
||||
(ex/raise :type :validation
|
||||
:code :missing-upload-file
|
||||
:hint "missing upload file"))
|
||||
|
||||
(let [project-id (some-> (profile/retrieve-additional-data pool profile-id) :default-project-id)
|
||||
(let [profile (profile/get-profile pool profile-id)
|
||||
project-id (:default-project-id profile)
|
||||
overwrite? (contains? params :overwrite)
|
||||
migrate? (contains? params :migrate)
|
||||
migrate? (contains? params :migrate)
|
||||
ignore-index-errors? (contains? params :ignore-index-errors)]
|
||||
|
||||
(when-not project-id
|
||||
@@ -334,10 +351,9 @@
|
||||
::binf/profile-id profile-id
|
||||
::binf/project-id project-id))
|
||||
|
||||
(yrs/response
|
||||
:status 200
|
||||
:headers {"content-type" "text/plain"}
|
||||
:body "OK")))
|
||||
{::yrs/status 200
|
||||
::yrs/headers {"content-type" "text/plain"}
|
||||
::yrs/body "OK"}))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; OTHER SMALL VIEWS/HANDLERS
|
||||
@@ -345,15 +361,16 @@
|
||||
|
||||
(defn health-handler
|
||||
"Mainly a task that performs a health check."
|
||||
[{:keys [pool]} _]
|
||||
(db/with-atomic [conn pool]
|
||||
(try
|
||||
(db/exec-one! conn ["select count(*) as count from server_prop;"])
|
||||
(yrs/response 200 "OK")
|
||||
(catch Throwable cause
|
||||
(l/warn :hint "unable to execute query on health handler"
|
||||
:cause cause)
|
||||
(yrs/response 503 "KO")))))
|
||||
[{:keys [::db/pool]} _]
|
||||
(try
|
||||
(db/exec-one! pool ["select count(*) as count from server_prop;"])
|
||||
{::yrs/status 200
|
||||
::yrs/body "OK"}
|
||||
(catch Throwable cause
|
||||
(l/warn :hint "unable to execute query on health handler"
|
||||
:cause cause)
|
||||
{::yrs/status 503
|
||||
::yrs/body "KO"})))
|
||||
|
||||
(defn changelog-handler
|
||||
[_ _]
|
||||
@@ -362,10 +379,11 @@
|
||||
(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"))))
|
||||
{::yrs/status 200
|
||||
::yrs/headers {"content-type" "text/html; charset=utf-8"}
|
||||
::yrs/body (-> clog slurp md->html)}
|
||||
{::yrs/status 404
|
||||
::yrs/body "NOT FOUND"})))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; INIT
|
||||
@@ -375,32 +393,26 @@
|
||||
{:compile
|
||||
(fn [& _]
|
||||
(fn [handler pool]
|
||||
(fn [request respond raise]
|
||||
(fn [request]
|
||||
(if (authorized? pool request)
|
||||
(handler request respond raise)
|
||||
(raise (ex/error :type :authentication
|
||||
:code :only-admins-allowed))))))})
|
||||
|
||||
(handler request)
|
||||
(ex/raise :type :authentication
|
||||
:code :only-admins-allowed)))))})
|
||||
|
||||
(defmethod ig/pre-init-spec ::routes [_]
|
||||
(s/keys :req-un [::db/pool ::wrk/executor ::session/session]))
|
||||
(s/keys :req [::db/pool ::session/manager]))
|
||||
|
||||
(defmethod ig/init-key ::routes
|
||||
[_ {:keys [session pool executor] :as cfg}]
|
||||
[["/readyz" {:middleware [[mw/with-dispatch executor]
|
||||
[mw/with-config cfg]]
|
||||
:handler health-handler}]
|
||||
["/dbg" {:middleware [[session/middleware-2 session]
|
||||
[with-authorization pool]
|
||||
[mw/with-dispatch executor]
|
||||
[mw/with-config cfg]]}
|
||||
["" {:handler index-handler}]
|
||||
["/health" {:handler health-handler}]
|
||||
["/changelog" {:handler changelog-handler}]
|
||||
;; ["/error-by-id/:id" {:handler error-handler}]
|
||||
["/error/:id" {:handler error-handler}]
|
||||
["/error" {:handler error-list-handler}]
|
||||
["/file/export" {:handler export-handler}]
|
||||
["/file/import" {:handler import-handler}]
|
||||
["/file/data" {:handler file-data-handler}]
|
||||
["/file/changes" {:handler file-changes-handler}]]])
|
||||
[_ {:keys [::db/pool] :as cfg}]
|
||||
[["/readyz" {:handler (partial health-handler cfg)}]
|
||||
["/dbg" {:middleware [[session/authz cfg]
|
||||
[with-authorization pool]]}
|
||||
["" {:handler (partial index-handler cfg)}]
|
||||
["/health" {:handler (partial health-handler cfg)}]
|
||||
["/changelog" {:handler (partial changelog-handler cfg)}]
|
||||
["/error/:id" {:handler (partial error-handler cfg)}]
|
||||
["/error" {:handler (partial error-list-handler cfg)}]
|
||||
["/file/export" {:handler (partial export-handler cfg)}]
|
||||
["/file/import" {:handler (partial import-handler cfg)}]
|
||||
["/file/data" {:handler (partial file-data-handler cfg)}]
|
||||
["/file/changes" {:handler (partial file-changes-handler cfg)}]]])
|
||||
|
||||
@@ -7,37 +7,37 @@
|
||||
(ns app.http.errors
|
||||
"A errors handling for the http server."
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.logging :as l]
|
||||
[app.common.schema :as sm]
|
||||
[app.http :as-alias http]
|
||||
[app.http.access-token :as-alias actoken]
|
||||
[app.http.session :as-alias session]
|
||||
[clojure.spec.alpha :as s]
|
||||
[cuerdas.core :as str]
|
||||
[yetti.request :as yrq]
|
||||
[yetti.response :as yrs]))
|
||||
|
||||
(def ^:dynamic *context* {})
|
||||
|
||||
(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
|
||||
(defn request->context
|
||||
"Extracts error report relevant context data from request."
|
||||
[request]
|
||||
(let [claims (:session-token-claims request)]
|
||||
(merge
|
||||
*context*
|
||||
{:path (:path request)
|
||||
:method (:method request)
|
||||
:params (:params request)
|
||||
:ip-addr (parse-client-ip request)}
|
||||
(d/without-nils
|
||||
{:user-agent (yrq/get-header request "user-agent")
|
||||
:frontend-version (or (yrq/get-header request "x-frontend-version")
|
||||
"unknown")
|
||||
:profile-id (:uid claims)}))))
|
||||
(let [claims (-> {}
|
||||
(into (::session/token-claims request))
|
||||
(into (::actoken/token-claims request)))]
|
||||
{:path (:path request)
|
||||
:method (:method request)
|
||||
:params (:params request)
|
||||
:ip-addr (parse-client-ip request)
|
||||
:user-agent (yrq/get-header request "user-agent")
|
||||
:profile-id (:uid claims)
|
||||
:version (or (yrq/get-header request "x-frontend-version")
|
||||
"unknown")}))
|
||||
|
||||
(defmulti handle-exception
|
||||
(fn [err & _rest]
|
||||
@@ -47,16 +47,30 @@
|
||||
|
||||
(defmethod handle-exception :authentication
|
||||
[err _]
|
||||
(yrs/response 401 (ex-data err)))
|
||||
{::yrs/status 401
|
||||
::yrs/body (ex-data err)})
|
||||
|
||||
(defmethod handle-exception :authorization
|
||||
[err _]
|
||||
{::yrs/status 403
|
||||
::yrs/body (ex-data err)})
|
||||
|
||||
(defmethod handle-exception :restriction
|
||||
[err _]
|
||||
(yrs/response 400 (ex-data err)))
|
||||
{::yrs/status 400
|
||||
::yrs/body (ex-data err)})
|
||||
|
||||
(defmethod handle-exception :rate-limit
|
||||
[err _]
|
||||
(let [headers (-> err ex-data ::http/headers)]
|
||||
(yrs/response :status 429 :body "" :headers headers)))
|
||||
{::yrs/status 429
|
||||
::yrs/headers headers}))
|
||||
|
||||
(defmethod handle-exception :concurrency-limit
|
||||
[err _]
|
||||
(let [headers (-> err ex-data ::http/headers)]
|
||||
{::yrs/status 429
|
||||
::yrs/headers headers}))
|
||||
|
||||
(defmethod handle-exception :validation
|
||||
[err _]
|
||||
@@ -64,74 +78,99 @@
|
||||
(cond
|
||||
(= code :spec-validation)
|
||||
(let [explain (ex/explain data)]
|
||||
(yrs/response :status 400
|
||||
:body (-> data
|
||||
(dissoc ::s/problems ::s/value)
|
||||
(cond-> explain (assoc :explain explain)))))
|
||||
{::yrs/status 400
|
||||
::yrs/body (-> data
|
||||
(dissoc ::s/problems ::s/value)
|
||||
(cond-> explain (assoc :explain explain)))})
|
||||
|
||||
(= code :params-validation)
|
||||
(let [explain (::sm/explain data)
|
||||
payload (sm/humanize-data explain)]
|
||||
{::yrs/status 400
|
||||
::yrs/body (-> data
|
||||
(dissoc ::sm/explain)
|
||||
(assoc :data payload))})
|
||||
|
||||
(= code :request-body-too-large)
|
||||
(yrs/response :status 413 :body data)
|
||||
{::yrs/status 413 ::yrs/body data}
|
||||
|
||||
:else
|
||||
(yrs/response :status 400 :body data))))
|
||||
{::yrs/status 400 ::yrs/body data})))
|
||||
|
||||
(defmethod handle-exception :assertion
|
||||
[error request]
|
||||
(let [edata (ex-data error)
|
||||
explain (ex/explain edata)]
|
||||
(l/error :hint (ex-message error)
|
||||
:cause error
|
||||
::l/context (get-context request))
|
||||
(yrs/response :status 500
|
||||
:body {:type :server-error
|
||||
:code :assertion
|
||||
:data (-> edata
|
||||
(dissoc ::s/problems ::s/value ::s/spec)
|
||||
(cond-> explain (assoc :explain explain)))})))
|
||||
(binding [l/*context* (request->context request)]
|
||||
(let [{:keys [code] :as data} (ex-data error)]
|
||||
(cond
|
||||
(= code :data-validation)
|
||||
(let [explain (::sm/explain data)
|
||||
payload (sm/humanize-data explain)]
|
||||
(l/error :hint "Data assertion error" :message (ex-message error) :cause error)
|
||||
{::yrs/status 500
|
||||
::yrs/body {:type :server-error
|
||||
:code :assertion
|
||||
:data (-> data
|
||||
(dissoc ::sm/explain)
|
||||
(assoc :data payload))}})
|
||||
|
||||
(= code :spec-validation)
|
||||
(let [explain (ex/explain data)]
|
||||
(l/error :hint "Spec assertion error" :message (ex-message error) :cause error)
|
||||
{::yrs/status 500
|
||||
::yrs/body {:type :server-error
|
||||
:code :assertion
|
||||
:data (-> data
|
||||
(dissoc ::s/problems ::s/value ::s/spec)
|
||||
(cond-> explain (assoc :explain explain)))}})
|
||||
|
||||
:else
|
||||
(do
|
||||
(l/error :hint "Assertion error" :message (ex-message error) :cause error)
|
||||
{::yrs/status 500
|
||||
::yrs/body {:type :server-error
|
||||
:code :assertion
|
||||
:data data}})))))
|
||||
|
||||
|
||||
(defmethod handle-exception :not-found
|
||||
[err _]
|
||||
(yrs/response 404 (ex-data err)))
|
||||
{::yrs/status 404
|
||||
::yrs/body (ex-data err)})
|
||||
|
||||
(defmethod handle-exception :internal
|
||||
[error request]
|
||||
(let [{:keys [code] :as edata} (ex-data error)]
|
||||
(cond
|
||||
(= :concurrency-limit-reached code)
|
||||
(yrs/response 429)
|
||||
|
||||
:else
|
||||
(do
|
||||
(l/error :hint (ex-message error)
|
||||
:cause error
|
||||
::l/context (get-context request))
|
||||
(yrs/response 500 {:type :server-error
|
||||
:code :unhandled
|
||||
:hint (ex-message error)
|
||||
:data edata})))))
|
||||
(binding [l/*context* (request->context request)]
|
||||
(l/error :hint "Internal error" :message (ex-message error) :cause error)
|
||||
{::yrs/status 500
|
||||
::yrs/body {:type :server-error
|
||||
:code :unhandled
|
||||
:hint (ex-message error)
|
||||
:data (ex-data error)}}))
|
||||
|
||||
(defmethod handle-exception org.postgresql.util.PSQLException
|
||||
[error request]
|
||||
(let [state (.getSQLState ^java.sql.SQLException error)]
|
||||
(l/error :hint (ex-message error)
|
||||
:cause error
|
||||
::l/context (get-context request))
|
||||
(cond
|
||||
(= state "57014")
|
||||
(yrs/response 504 {:type :server-error
|
||||
:code :statement-timeout
|
||||
:hint (ex-message error)})
|
||||
(binding [l/*context* (request->context request)]
|
||||
(l/error :hint "PSQL error" :message (ex-message error) :cause error)
|
||||
(cond
|
||||
(= state "57014")
|
||||
{::yrs/status 504
|
||||
::yrs/body {: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)})
|
||||
(= state "25P03")
|
||||
{::yrs/status 504
|
||||
::yrs/body {: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}))))
|
||||
:else
|
||||
{::yrs/status 500
|
||||
::yrs/body {:type :server-error
|
||||
:code :unexpected
|
||||
:hint (ex-message error)
|
||||
:state state}}))))
|
||||
|
||||
(defmethod handle-exception :default
|
||||
[error request]
|
||||
@@ -139,13 +178,12 @@
|
||||
(cond
|
||||
;; This means that exception is not a controlled exception.
|
||||
(nil? edata)
|
||||
(do
|
||||
(l/error :hint (ex-message error)
|
||||
:cause error
|
||||
::l/context (get-context request))
|
||||
(yrs/response 500 {:type :server-error
|
||||
:code :unexpected
|
||||
:hint (ex-message error)}))
|
||||
(binding [l/*context* (request->context request)]
|
||||
(l/error :hint "Unexpected error" :message (ex-message error) :cause error)
|
||||
{::yrs/status 500
|
||||
::yrs/body {:type :server-error
|
||||
:code :unexpected
|
||||
:hint (ex-message error)}})
|
||||
|
||||
;; This is a special case for the idle-in-transaction error;
|
||||
;; when it happens, the connection is automatically closed and
|
||||
@@ -157,27 +195,17 @@
|
||||
(handle-exception (:handling edata) request)
|
||||
|
||||
:else
|
||||
(do
|
||||
(l/error :hint (ex-message error)
|
||||
:cause error
|
||||
::l/context (get-context request))
|
||||
(yrs/response 500 {:type :server-error
|
||||
:code :unhandled
|
||||
:hint (ex-message error)
|
||||
:data edata})))))
|
||||
(binding [l/*context* (request->context request)]
|
||||
(l/error :hint "Unhandled error" :message (ex-message error) :cause error)
|
||||
{::yrs/status 500
|
||||
::yrs/body {:type :server-error
|
||||
:code :unhandled
|
||||
:hint (ex-message error)
|
||||
:data edata}}))))
|
||||
|
||||
(defn handle
|
||||
[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
|
||||
(if (or (instance? java.util.concurrent.CompletionException cause)
|
||||
(instance? java.util.concurrent.ExecutionException cause))
|
||||
(handle-exception (ex-cause cause) request)
|
||||
(handle-exception cause request)))
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC
|
||||
|
||||
(ns app.http.feedback
|
||||
"A general purpose feedback module."
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.spec :as us]
|
||||
[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]
|
||||
[promesa.core :as p]
|
||||
[promesa.exec :as px]
|
||||
[yetti.request :as yrq]
|
||||
[yetti.response :as yrs]))
|
||||
|
||||
(declare ^:private send-feedback)
|
||||
(declare ^:private handler)
|
||||
|
||||
(defmethod ig/pre-init-spec ::handler [_]
|
||||
(s/keys :req-un [::db/pool ::wrk/executor]))
|
||||
|
||||
(defmethod ig/init-key ::handler
|
||||
[_ {: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"))))))
|
||||
|
||||
(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))
|
||||
|
||||
(= token ftoken)
|
||||
(send-feedback cfg nil params))
|
||||
|
||||
(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
|
||||
[pool profile params]
|
||||
(let [params (us/conform ::feedback params)
|
||||
destination (cf/get :feedback-destination)]
|
||||
(eml/send! {::eml/conn pool
|
||||
::eml/factory eml/feedback
|
||||
:from destination
|
||||
:to destination
|
||||
:profile profile
|
||||
:reply-to (:from params)
|
||||
:email (:from params)
|
||||
:subject (:subject params)
|
||||
:content (:content params)})
|
||||
nil))
|
||||
@@ -14,6 +14,7 @@
|
||||
[cuerdas.core :as str]
|
||||
[promesa.core :as p]
|
||||
[promesa.exec :as px]
|
||||
[promesa.util :as pu]
|
||||
[yetti.adapter :as yt]
|
||||
[yetti.middleware :as ymw]
|
||||
[yetti.request :as yrq]
|
||||
@@ -22,7 +23,10 @@
|
||||
com.fasterxml.jackson.core.JsonParseException
|
||||
com.fasterxml.jackson.core.io.JsonEOFException
|
||||
io.undertow.server.RequestTooBigException
|
||||
java.io.OutputStream))
|
||||
java.io.OutputStream
|
||||
java.io.InputStream))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(def server-timing
|
||||
{:name ::server-timing
|
||||
@@ -44,14 +48,14 @@
|
||||
(let [header (yrq/get-header request "content-type")]
|
||||
(cond
|
||||
(str/starts-with? header "application/transit+json")
|
||||
(with-open [is (yrq/body request)]
|
||||
(with-open [^InputStream is (yrq/body request)]
|
||||
(let [params (t/read! (t/reader is))]
|
||||
(-> request
|
||||
(assoc :body-params params)
|
||||
(update :params merge params))))
|
||||
|
||||
(str/starts-with? header "application/json")
|
||||
(with-open [is (yrq/body request)]
|
||||
(with-open [^InputStream is (yrq/body request)]
|
||||
(let [params (json/decode is json-mapper)]
|
||||
(-> request
|
||||
(assoc :body-params params)
|
||||
@@ -62,6 +66,11 @@
|
||||
|
||||
(handle-error [raise cause]
|
||||
(cond
|
||||
(instance? RuntimeException cause)
|
||||
(if-let [cause (ex-cause cause)]
|
||||
(handle-error raise cause)
|
||||
(raise cause))
|
||||
|
||||
(instance? RequestTooBigException cause)
|
||||
(raise (ex/error :type :validation
|
||||
:code :request-body-too-large
|
||||
@@ -78,12 +87,12 @@
|
||||
(raise cause)))]
|
||||
|
||||
(fn [request respond raise]
|
||||
(let [request (ex/try! (process-request request))]
|
||||
(if (ex/exception? request)
|
||||
(if (instance? RuntimeException request)
|
||||
(handle-error raise (or (ex/cause request) request))
|
||||
(handle-error raise request))
|
||||
(handler request respond raise))))))
|
||||
(if (= (yrq/method request) :post)
|
||||
(let [request (ex/try! (process-request request))]
|
||||
(if (ex/exception? request)
|
||||
(handle-error raise request)
|
||||
(handler request respond raise)))
|
||||
(handler request respond raise)))))
|
||||
|
||||
(def parse-request
|
||||
{:name ::parse-request
|
||||
@@ -94,12 +103,7 @@
|
||||
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))))
|
||||
(yetti.util.BufferedOutputStream. os (int chunk-size)))
|
||||
|
||||
(def ^:const buffer-size (:xnio/buffer-size yt/defaults))
|
||||
|
||||
@@ -109,16 +113,12 @@
|
||||
(reify yrs/StreamableResponseBody
|
||||
(-write-body-to-stream [_ _ output-stream]
|
||||
(try
|
||||
(with-open [bos (buffered-output-stream output-stream buffer-size)]
|
||||
(with-open [^OutputStream bos (buffered-output-stream output-stream buffer-size)]
|
||||
(let [tw (t/writer bos opts)]
|
||||
(t/write! tw data)))
|
||||
|
||||
(catch java.io.IOException _cause
|
||||
;; Do nothing, EOF means client closes connection abruptly
|
||||
nil)
|
||||
(catch java.io.IOException _)
|
||||
(catch Throwable cause
|
||||
(l/warn :hint "unexpected error on encoding response"
|
||||
:cause cause))
|
||||
(l/error :hint "unexpected error on encoding response" :cause cause))
|
||||
(finally
|
||||
(.close ^OutputStream output-stream))))))
|
||||
|
||||
@@ -126,29 +126,26 @@
|
||||
(reify yrs/StreamableResponseBody
|
||||
(-write-body-to-stream [_ _ output-stream]
|
||||
(try
|
||||
|
||||
(with-open [bos (buffered-output-stream output-stream buffer-size)]
|
||||
(with-open [^OutputStream bos (buffered-output-stream output-stream buffer-size)]
|
||||
(json/write! bos data json-mapper))
|
||||
|
||||
(catch java.io.IOException _cause
|
||||
;; Do nothing, EOF means client closes connection abruptly
|
||||
nil)
|
||||
(catch java.io.IOException _)
|
||||
(catch Throwable cause
|
||||
(l/warn :hint "unexpected error on encoding response"
|
||||
:cause cause))
|
||||
(l/error :hint "unexpected error on encoding response"
|
||||
:cause cause))
|
||||
(finally
|
||||
(.close ^OutputStream output-stream))))))
|
||||
|
||||
(format-response-with-json [response _]
|
||||
(let [body (yrs/body response)]
|
||||
(let [body (::yrs/body response)]
|
||||
(if (or (boolean? body) (coll? body))
|
||||
(-> response
|
||||
(update :headers assoc "content-type" "application/json")
|
||||
(assoc :body (json-streamable-body body)))
|
||||
(update ::yrs/headers assoc "content-type" "application/json")
|
||||
(assoc ::yrs/body (json-streamable-body body)))
|
||||
response)))
|
||||
|
||||
(format-response-with-transit [response request]
|
||||
(let [body (yrs/body response)]
|
||||
(let [body (::yrs/body response)]
|
||||
(if (or (boolean? body) (coll? body))
|
||||
(let [qs (yrq/query request)
|
||||
opts (if (or (contains? cf/flags :transit-readable-response)
|
||||
@@ -156,12 +153,17 @@
|
||||
{:type :json-verbose}
|
||||
{:type :json})]
|
||||
(-> response
|
||||
(update :headers assoc "content-type" "application/transit+json")
|
||||
(assoc :body (transit-streamable-body body opts))))
|
||||
(update ::yrs/headers assoc "content-type" "application/transit+json")
|
||||
(assoc ::yrs/body (transit-streamable-body body opts))))
|
||||
response)))
|
||||
|
||||
(format-from-params [{:keys [query-params] :as request}]
|
||||
(and (= "json" (get query-params :_fmt))
|
||||
"application/json"))
|
||||
|
||||
(format-response [response request]
|
||||
(let [accept (yrq/get-header request "accept")]
|
||||
(let [accept (or (format-from-params request)
|
||||
(yrq/get-header request "accept"))]
|
||||
(cond
|
||||
(or (= accept "application/transit+json")
|
||||
(str/includes? accept "application/transit+json"))
|
||||
@@ -181,8 +183,7 @@
|
||||
(fn [request respond raise]
|
||||
(handler request
|
||||
(fn [response]
|
||||
(let [response (process-response response request)]
|
||||
(respond response)))
|
||||
(respond (process-response response request)))
|
||||
raise))))
|
||||
|
||||
(def format-response
|
||||
@@ -191,74 +192,59 @@
|
||||
|
||||
(defn wrap-errors
|
||||
[handler on-error]
|
||||
(fn [request respond _]
|
||||
(fn [request respond raise]
|
||||
(handler request respond (fn [cause]
|
||||
(-> cause (on-error request) respond)))))
|
||||
(try
|
||||
(respond (on-error cause request))
|
||||
(catch Throwable cause
|
||||
(raise cause)))))))
|
||||
|
||||
(def errors
|
||||
{:name ::errors
|
||||
:compile (constantly wrap-errors)})
|
||||
|
||||
(defn- with-cors-headers
|
||||
[headers 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")))
|
||||
|
||||
(defn wrap-cors
|
||||
[handler]
|
||||
(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"))))
|
||||
|
||||
(update-response [response request]
|
||||
(update response :headers add-headers 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))))))
|
||||
(fn [request]
|
||||
(let [response (if (= (yrq/method request) :options)
|
||||
{::yrs/status 200}
|
||||
(handler request))
|
||||
origin (yrq/get-header request "origin")]
|
||||
(update response ::yrs/headers with-cors-headers origin))))
|
||||
|
||||
(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))))))))
|
||||
:compile (fn [& _]
|
||||
(when (contains? cf/flags :cors)
|
||||
wrap-cors))})
|
||||
|
||||
(def restrict-methods
|
||||
{:name ::restrict-methods
|
||||
:compile compile-restrict-methods})
|
||||
:compile
|
||||
(fn [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/status 405})))))))})
|
||||
|
||||
(def with-dispatch
|
||||
{:name ::with-dispatch
|
||||
:compile
|
||||
(fn [& _]
|
||||
(fn [handler executor]
|
||||
(fn [request respond raise]
|
||||
(-> (px/submit! executor #(handler request))
|
||||
(p/bind p/wrap)
|
||||
(p/then respond)
|
||||
(p/catch raise)))))})
|
||||
|
||||
(def with-config
|
||||
{:name ::with-config
|
||||
:compile
|
||||
(fn [& _]
|
||||
(fn [handler config]
|
||||
(fn
|
||||
([request] (handler config request))
|
||||
([request respond raise] (handler config request respond raise)))))})
|
||||
(let [executor (px/resolve-executor executor)]
|
||||
(fn [request respond raise]
|
||||
(->> (px/submit! executor (partial handler request))
|
||||
(p/fnly (pu/handler respond raise)))))))})
|
||||
|
||||
@@ -9,17 +9,17 @@
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.logging :as l]
|
||||
[app.common.spec :as us]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.db.sql :as sql]
|
||||
[app.http.session.tasks :as-alias tasks]
|
||||
[app.main :as-alias main]
|
||||
[app.tokens :as tokens]
|
||||
[app.util.time :as dt]
|
||||
[app.worker :as wrk]
|
||||
[clojure.spec.alpha :as s]
|
||||
[cuerdas.core :as str]
|
||||
[integrant.core :as ig]
|
||||
[promesa.core :as p]
|
||||
[promesa.exec :as px]
|
||||
[yetti.request :as yrq]))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
@@ -45,97 +45,83 @@
|
||||
|
||||
(defprotocol ISessionManager
|
||||
(read [_ key])
|
||||
(decode [_ key])
|
||||
(write! [_ key data])
|
||||
(update! [_ data])
|
||||
(delete! [_ key]))
|
||||
|
||||
(s/def ::session #(satisfies? ISessionManager %))
|
||||
(s/def ::manager #(satisfies? ISessionManager %))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; STORAGE IMPL
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(s/def ::session-params
|
||||
(s/keys :req-un [::user-agent
|
||||
::profile-id
|
||||
::created-at]))
|
||||
|
||||
(defn- prepare-session-params
|
||||
[props data]
|
||||
(let [profile-id (:profile-id data)
|
||||
user-agent (:user-agent data)
|
||||
created-at (or (:created-at data) (dt/now))
|
||||
token (tokens/generate props {:iss "authentication"
|
||||
:iat created-at
|
||||
:uid profile-id})]
|
||||
{:user-agent user-agent
|
||||
:profile-id profile-id
|
||||
:created-at created-at
|
||||
:updated-at created-at
|
||||
:id token}))
|
||||
[key params]
|
||||
(us/assert! ::us/not-empty-string key)
|
||||
(us/assert! ::session-params params)
|
||||
|
||||
{:user-agent (:user-agent params)
|
||||
:profile-id (:profile-id params)
|
||||
:created-at (:created-at params)
|
||||
:updated-at (:created-at params)
|
||||
:id key})
|
||||
|
||||
(defn- database-manager
|
||||
[{:keys [::db/pool ::wrk/executor ::main/props]}]
|
||||
[pool]
|
||||
(reify ISessionManager
|
||||
(read [_ token]
|
||||
(px/with-dispatch executor
|
||||
(db/exec-one! pool (sql/select :http-session {:id token}))))
|
||||
(db/exec-one! pool (sql/select :http-session {:id token})))
|
||||
|
||||
(decode [_ token]
|
||||
(px/with-dispatch executor
|
||||
(tokens/verify props {:token token :iss "authentication"})))
|
||||
(write! [_ key params]
|
||||
(let [params (prepare-session-params key params)]
|
||||
(db/insert! pool :http-session params)
|
||||
params))
|
||||
|
||||
(write! [_ _ data]
|
||||
(px/with-dispatch executor
|
||||
(let [params (prepare-session-params props data)]
|
||||
(db/insert! pool :http-session params)
|
||||
params)))
|
||||
|
||||
(update! [_ data]
|
||||
(update! [_ params]
|
||||
(let [updated-at (dt/now)]
|
||||
(px/with-dispatch executor
|
||||
(db/update! pool :http-session
|
||||
{:updated-at updated-at}
|
||||
{:id (:id data)})
|
||||
(assoc data :updated-at updated-at))))
|
||||
(db/update! pool :http-session
|
||||
{:updated-at updated-at}
|
||||
{:id (:id params)})
|
||||
(assoc params :updated-at updated-at)))
|
||||
|
||||
(delete! [_ token]
|
||||
(px/with-dispatch executor
|
||||
(db/delete! pool :http-session {:id token})
|
||||
nil))))
|
||||
(db/delete! pool :http-session {:id token})
|
||||
nil)))
|
||||
|
||||
(defn inmemory-manager
|
||||
[{:keys [::wrk/executor ::main/props]}]
|
||||
[]
|
||||
(let [cache (atom {})]
|
||||
(reify ISessionManager
|
||||
(read [_ token]
|
||||
(p/do (get @cache token)))
|
||||
(get @cache token))
|
||||
|
||||
(decode [_ token]
|
||||
(px/with-dispatch executor
|
||||
(tokens/verify props {:token token :iss "authentication"})))
|
||||
(write! [_ key params]
|
||||
(let [params (prepare-session-params key params)]
|
||||
(swap! cache assoc key params)
|
||||
params))
|
||||
|
||||
(write! [_ _ data]
|
||||
(p/do
|
||||
(let [{:keys [token] :as params} (prepare-session-params props data)]
|
||||
(swap! cache assoc token params)
|
||||
params)))
|
||||
|
||||
(update! [_ data]
|
||||
(p/do
|
||||
(let [updated-at (dt/now)]
|
||||
(swap! cache update (:id data) assoc :updated-at updated-at)
|
||||
(assoc data :updated-at updated-at))))
|
||||
(update! [_ params]
|
||||
(let [updated-at (dt/now)]
|
||||
(swap! cache update (:id params) assoc :updated-at updated-at)
|
||||
(assoc params :updated-at updated-at)))
|
||||
|
||||
(delete! [_ token]
|
||||
(p/do
|
||||
(swap! cache dissoc token)
|
||||
nil)))))
|
||||
(swap! cache dissoc token)
|
||||
nil))))
|
||||
|
||||
(defmethod ig/pre-init-spec ::manager [_]
|
||||
(s/keys :req [::db/pool ::wrk/executor ::main/props]))
|
||||
(s/keys :req [::db/pool]))
|
||||
|
||||
(defmethod ig/init-key ::manager
|
||||
[_ {:keys [::db/pool] :as cfg}]
|
||||
[_ {:keys [::db/pool]}]
|
||||
(if (db/read-only? pool)
|
||||
(inmemory-manager cfg)
|
||||
(database-manager cfg)))
|
||||
(inmemory-manager)
|
||||
(database-manager pool)))
|
||||
|
||||
(defmethod ig/halt-key! ::manager
|
||||
[_ _])
|
||||
@@ -144,101 +130,111 @@
|
||||
;; MANAGER IMPL
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(declare assign-auth-token-cookie)
|
||||
(declare assign-authenticated-cookie)
|
||||
(declare clear-auth-token-cookie)
|
||||
(declare clear-authenticated-cookie)
|
||||
(declare ^:private assign-auth-token-cookie)
|
||||
(declare ^:private assign-authenticated-cookie)
|
||||
(declare ^:private clear-auth-token-cookie)
|
||||
(declare ^:private clear-authenticated-cookie)
|
||||
(declare ^:private gen-token)
|
||||
|
||||
(defn create-fn
|
||||
[manager profile-id]
|
||||
[{:keys [::manager ::main/props]} profile-id]
|
||||
(us/assert! ::manager manager)
|
||||
(us/assert! ::us/uuid profile-id)
|
||||
|
||||
(fn [request response]
|
||||
(let [uagent (yrq/get-header request "user-agent")
|
||||
params {:profile-id profile-id
|
||||
:user-agent uagent}]
|
||||
(-> (write! manager nil params)
|
||||
(p/then (fn [session]
|
||||
(l/trace :hint "create" :profile-id profile-id)
|
||||
(-> response
|
||||
(assign-auth-token-cookie session)
|
||||
(assign-authenticated-cookie session))))))))
|
||||
:user-agent uagent
|
||||
:created-at (dt/now)}
|
||||
token (gen-token props params)
|
||||
session (write! manager token params)]
|
||||
(l/trace :hint "create" :profile-id (str profile-id))
|
||||
(-> response
|
||||
(assign-auth-token-cookie session)
|
||||
(assign-authenticated-cookie session)))))
|
||||
|
||||
(defn delete-fn
|
||||
[manager]
|
||||
(letfn [(delete [{:keys [profile-id] :as request}]
|
||||
(let [cname (cf/get :auth-token-cookie-name default-auth-token-cookie-name)
|
||||
cookie (yrq/get-cookie request cname)]
|
||||
(l/trace :hint "delete" :profile-id profile-id)
|
||||
(some->> (:value cookie) (delete! manager))))]
|
||||
(fn [request response]
|
||||
(p/do
|
||||
(delete request)
|
||||
(-> response
|
||||
(assoc :status 204)
|
||||
(assoc :body nil)
|
||||
(clear-auth-token-cookie)
|
||||
(clear-authenticated-cookie))))))
|
||||
[{:keys [::manager]}]
|
||||
(us/assert! ::manager manager)
|
||||
(fn [request response]
|
||||
(let [cname (cf/get :auth-token-cookie-name default-auth-token-cookie-name)
|
||||
cookie (yrq/get-cookie request cname)]
|
||||
(l/trace :hint "delete" :profile-id (:profile-id request))
|
||||
(some->> (:value cookie) (delete! manager))
|
||||
(-> response
|
||||
(assoc :status 204)
|
||||
(assoc :body nil)
|
||||
(clear-auth-token-cookie)
|
||||
(clear-authenticated-cookie)))))
|
||||
|
||||
(def middleware-1
|
||||
(letfn [(decode-cookie [manager cookie]
|
||||
(if-let [value (:value cookie)]
|
||||
(decode manager value)
|
||||
(p/resolved nil)))
|
||||
(defn- gen-token
|
||||
[props {:keys [profile-id created-at]}]
|
||||
(tokens/generate props {:iss "authentication"
|
||||
:iat created-at
|
||||
:uid profile-id}))
|
||||
(defn- decode-token
|
||||
[props token]
|
||||
(when token
|
||||
(tokens/verify props {:token token :iss "authentication"})))
|
||||
|
||||
(wrap-handler [manager handler request respond raise]
|
||||
(let [cookie (some->> (cf/get :auth-token-cookie-name default-auth-token-cookie-name)
|
||||
(yrq/get-cookie request))]
|
||||
(->> (decode-cookie manager cookie)
|
||||
(p/fnly (fn [claims _]
|
||||
(cond-> request
|
||||
(some? claims) (assoc :session-token-claims claims)
|
||||
:always (handler respond raise)))))))]
|
||||
{:name :session-1
|
||||
:compile (fn [& _]
|
||||
(fn [handler manager]
|
||||
(partial wrap-handler manager handler)))}))
|
||||
(defn- get-token
|
||||
[request]
|
||||
(let [cname (cf/get :auth-token-cookie-name default-auth-token-cookie-name)
|
||||
cookie (some-> (yrq/get-cookie request cname) :value)]
|
||||
(when-not (str/empty? cookie)
|
||||
cookie)))
|
||||
|
||||
(def middleware-2
|
||||
(letfn [(wrap-handler [manager handler request respond raise]
|
||||
(-> (retrieve-session manager request)
|
||||
(p/finally (fn [session cause]
|
||||
(cond
|
||||
(some? cause)
|
||||
(raise cause)
|
||||
(defn- get-session
|
||||
[manager token]
|
||||
(some->> token (read manager)))
|
||||
|
||||
(nil? session)
|
||||
(handler request respond raise)
|
||||
(defn- renew-session?
|
||||
[{:keys [updated-at] :as session}]
|
||||
(and (dt/instant? updated-at)
|
||||
(let [elapsed (dt/diff updated-at (dt/now))]
|
||||
(neg? (compare default-renewal-max-age elapsed)))))
|
||||
|
||||
:else
|
||||
(let [request (-> request
|
||||
(assoc :profile-id (:profile-id session))
|
||||
(assoc :session-id (:id session)))
|
||||
respond (cond-> respond
|
||||
(renew-session? session)
|
||||
(wrap-respond manager session))]
|
||||
(handler request respond raise)))))))
|
||||
(defn- wrap-soft-auth
|
||||
[handler {:keys [::manager ::main/props]}]
|
||||
(us/assert! ::manager manager)
|
||||
(letfn [(handle-request [request]
|
||||
(try
|
||||
(let [token (get-token request)
|
||||
claims (decode-token props token)]
|
||||
(cond-> request
|
||||
(map? claims)
|
||||
(-> (assoc ::token-claims claims)
|
||||
(assoc ::token token))))
|
||||
(catch Throwable cause
|
||||
(l/trace :hint "exception on decoding malformed token" :cause cause)
|
||||
request)))]
|
||||
|
||||
(retrieve-session [manager request]
|
||||
(let [cname (cf/get :auth-token-cookie-name default-auth-token-cookie-name)
|
||||
cookie (yrq/get-cookie request cname)]
|
||||
(some->> (:value cookie) (read manager))))
|
||||
(fn [request respond raise]
|
||||
(let [request (handle-request request)]
|
||||
(handler request respond raise)))))
|
||||
|
||||
(renew-session? [{:keys [updated-at] :as session}]
|
||||
(and (dt/instant? updated-at)
|
||||
(let [elapsed (dt/diff updated-at (dt/now))]
|
||||
(neg? (compare default-renewal-max-age elapsed)))))
|
||||
(defn- wrap-authz
|
||||
[handler {:keys [::manager]}]
|
||||
(us/assert! ::manager manager)
|
||||
(fn [request]
|
||||
(let [session (get-session manager (::token request))
|
||||
request (cond-> request
|
||||
(some? session)
|
||||
(assoc ::profile-id (:profile-id session)
|
||||
::id (:id session)))]
|
||||
|
||||
;; Wrap respond with session renewal code
|
||||
(wrap-respond [respond manager session]
|
||||
(fn [response]
|
||||
(p/let [session (update! manager session)]
|
||||
(-> response
|
||||
(assign-auth-token-cookie session)
|
||||
(assign-authenticated-cookie session)
|
||||
(respond)))))]
|
||||
(cond-> (handler request)
|
||||
(renew-session? session)
|
||||
(-> (assign-auth-token-cookie session)
|
||||
(assign-authenticated-cookie session))))))
|
||||
|
||||
{:name :session-2
|
||||
:compile (fn [& _]
|
||||
(fn [handler manager]
|
||||
(partial wrap-handler manager handler)))}))
|
||||
(def soft-auth
|
||||
{:name ::soft-auth
|
||||
:compile (constantly wrap-soft-auth)})
|
||||
|
||||
(def authz
|
||||
{:name ::authz
|
||||
:compile (constantly wrap-authz)})
|
||||
|
||||
;; --- IMPL
|
||||
|
||||
@@ -264,13 +260,16 @@
|
||||
(defn- assign-authenticated-cookie
|
||||
[response {updated-at :updated-at}]
|
||||
(let [max-age (cf/get :auth-token-cookie-max-age default-cookie-max-age)
|
||||
domain (cf/get :authenticated-cookie-domain)
|
||||
cname (cf/get :authenticated-cookie-name "authenticated")
|
||||
|
||||
created-at (or updated-at (dt/now))
|
||||
renewal (dt/plus created-at default-renewal-max-age)
|
||||
expires (dt/plus created-at max-age)
|
||||
|
||||
comment (str "Renewal at: " (dt/format-instant renewal :rfc1123))
|
||||
secure? (contains? cf/flags :secure-session-cookies)
|
||||
domain (cf/get :authenticated-cookie-domain)
|
||||
name (cf/get :authenticated-cookie-name "authenticated")
|
||||
|
||||
cookie {:domain domain
|
||||
:expires expires
|
||||
:path "/"
|
||||
@@ -280,41 +279,46 @@
|
||||
:secure secure?}]
|
||||
(cond-> response
|
||||
(string? domain)
|
||||
(update :cookies assoc name cookie))))
|
||||
(update :cookies assoc cname cookie))))
|
||||
|
||||
(defn- clear-auth-token-cookie
|
||||
[response]
|
||||
(let [cname (cf/get :auth-token-cookie-name default-auth-token-cookie-name)]
|
||||
(update response :cookies assoc cname {:path "/" :value "" :max-age -1})))
|
||||
(update response :cookies assoc cname {:path "/" :value "" :max-age 0})))
|
||||
|
||||
(defn- clear-authenticated-cookie
|
||||
[response]
|
||||
(let [cname (cf/get :authenticated-cookie-name default-authenticated-cookie-name)
|
||||
(let [cname (cf/get :authenticated-cookie-name default-authenticated-cookie-name)
|
||||
domain (cf/get :authenticated-cookie-domain)]
|
||||
(cond-> response
|
||||
(string? domain)
|
||||
(update :cookies assoc cname {:domain domain :path "/" :value "" :max-age -1}))))
|
||||
(update :cookies assoc cname {:domain domain :path "/" :value "" :max-age 0}))))
|
||||
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; TASK: SESSION GC
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(declare sql:delete-expired)
|
||||
(s/def ::tasks/max-age ::dt/duration)
|
||||
|
||||
(s/def ::max-age ::dt/duration)
|
||||
(defmethod ig/pre-init-spec ::tasks/gc [_]
|
||||
(s/keys :req [::db/pool]
|
||||
:opt [::tasks/max-age]))
|
||||
|
||||
(defmethod ig/pre-init-spec ::gc-task [_]
|
||||
(s/keys :req-un [::db/pool]
|
||||
:opt-un [::max-age]))
|
||||
|
||||
(defmethod ig/prep-key ::gc-task
|
||||
(defmethod ig/prep-key ::tasks/gc
|
||||
[_ cfg]
|
||||
(merge {:max-age default-cookie-max-age}
|
||||
(d/without-nils cfg)))
|
||||
(let [max-age (cf/get :auth-token-cookie-max-age default-cookie-max-age)]
|
||||
(merge {::tasks/max-age max-age} (d/without-nils cfg))))
|
||||
|
||||
(defmethod ig/init-key ::gc-task
|
||||
[_ {:keys [pool max-age] :as cfg}]
|
||||
(def ^:private
|
||||
sql:delete-expired
|
||||
"delete from http_session
|
||||
where updated_at < now() - ?::interval
|
||||
or (updated_at is null and
|
||||
created_at < now() - ?::interval)")
|
||||
|
||||
(defmethod ig/init-key ::tasks/gc
|
||||
[_ {:keys [::db/pool ::tasks/max-age] :as cfg}]
|
||||
(l/debug :hint "initializing session gc task" :max-age max-age)
|
||||
(fn [_]
|
||||
(db/with-atomic [conn pool]
|
||||
@@ -326,9 +330,3 @@
|
||||
:deleted result)
|
||||
result))))
|
||||
|
||||
(def ^:private
|
||||
sql:delete-expired
|
||||
"delete from http_session
|
||||
where updated_at < now() - ?::interval
|
||||
or (updated_at is null and
|
||||
created_at < now() - ?::interval)")
|
||||
|
||||
@@ -12,13 +12,14 @@
|
||||
[app.common.pprint :as pp]
|
||||
[app.common.spec :as us]
|
||||
[app.db :as db]
|
||||
[app.http.session :as session]
|
||||
[app.metrics :as mtx]
|
||||
[app.msgbus :as mbus]
|
||||
[app.util.time :as dt]
|
||||
[app.util.websocket :as ws]
|
||||
[clojure.core.async :as a]
|
||||
[clojure.spec.alpha :as s]
|
||||
[integrant.core :as ig]
|
||||
[promesa.exec.csp :as sp]
|
||||
[yetti.websocket :as yws]))
|
||||
|
||||
(def recv-labels
|
||||
@@ -33,71 +34,38 @@
|
||||
|
||||
(def state (atom {}))
|
||||
|
||||
(defn- on-connect
|
||||
[{:keys [metrics]} wsp]
|
||||
(let [created-at (dt/now)]
|
||||
(swap! state assoc (::ws/id @wsp) wsp)
|
||||
(mtx/run! metrics
|
||||
:id :websocket-active-connections
|
||||
:inc 1)
|
||||
(fn []
|
||||
(swap! state dissoc (::ws/id @wsp))
|
||||
(mtx/run! metrics :id :websocket-active-connections :dec 1)
|
||||
(mtx/run! metrics
|
||||
:id :websocket-session-timing
|
||||
:val (/ (inst-ms (dt/diff created-at (dt/now))) 1000.0)))))
|
||||
|
||||
(defn- on-rcv-message
|
||||
[{:keys [metrics]} _ message]
|
||||
(mtx/run! metrics
|
||||
:id :websocket-messages-total
|
||||
:labels recv-labels
|
||||
:inc 1)
|
||||
message)
|
||||
|
||||
(defn- on-snd-message
|
||||
[{:keys [metrics]} _ message]
|
||||
(mtx/run! metrics
|
||||
:id :websocket-messages-total
|
||||
:labels send-labels
|
||||
:inc 1)
|
||||
message)
|
||||
|
||||
;; REPL HELPERS
|
||||
|
||||
(defn repl-get-connections-for-file
|
||||
[file-id]
|
||||
(->> (vals @state)
|
||||
(filter #(= file-id (-> % deref ::file-subscription :file-id)))
|
||||
(map deref)
|
||||
(map ::ws/id)))
|
||||
|
||||
(defn repl-get-connections-for-team
|
||||
[team-id]
|
||||
(->> (vals @state)
|
||||
(filter #(= team-id (-> % deref ::team-subscription :team-id)))
|
||||
(map deref)
|
||||
(map ::ws/id)))
|
||||
|
||||
(defn repl-close-connection
|
||||
[id]
|
||||
(when-let [wsp (get @state id)]
|
||||
(a/>!! (::ws/close-ch @wsp) [8899 "closed from server"])
|
||||
(a/close! (::ws/close-ch @wsp))))
|
||||
(when-let [{:keys [::ws/close-ch] :as wsp} (get @state id)]
|
||||
(sp/put! close-ch [8899 "closed from server"])
|
||||
(sp/close! close-ch)))
|
||||
|
||||
(defn repl-get-connection-info
|
||||
[id]
|
||||
(when-let [wsp (get @state id)]
|
||||
{:id id
|
||||
:created-at (::created-at @wsp)
|
||||
:profile-id (::profile-id @wsp)
|
||||
:session-id (::session-id @wsp)
|
||||
:user-agent (::ws/user-agent @wsp)
|
||||
:ip-addr (::ws/remote-addr @wsp)
|
||||
:last-activity-at (::ws/last-activity-at @wsp)
|
||||
:http-session-id (::ws/http-session-id @wsp)
|
||||
:subscribed-file (-> wsp deref ::file-subscription :file-id)
|
||||
:subscribed-team (-> wsp deref ::team-subscription :team-id)}))
|
||||
:created-at (::created-at wsp)
|
||||
:profile-id (::profile-id wsp)
|
||||
:session-id (::session-id wsp)
|
||||
:user-agent (::ws/user-agent wsp)
|
||||
:ip-addr (::ws/remote-addr wsp)
|
||||
:last-activity-at (::ws/last-activity-at wsp)
|
||||
:subscribed-file (-> wsp ::file-subscription :file-id)
|
||||
:subscribed-team (-> wsp ::team-subscription :team-id)}))
|
||||
|
||||
(defn repl-print-connection-info
|
||||
[id]
|
||||
@@ -117,235 +85,234 @@
|
||||
(fn [_ _ message]
|
||||
(:type message)))
|
||||
|
||||
(defmethod handle-message :connect
|
||||
[cfg wsp _]
|
||||
(defmethod handle-message :open
|
||||
[{:keys [::mbus/msgbus]} {:keys [::ws/id ::ws/output-ch ::ws/state ::profile-id ::session-id] :as wsp} _]
|
||||
(l/trace :fn "handle-message" :event "open" :conn-id id)
|
||||
(let [ch (sp/chan :buf (sp/dropping-buffer 16)
|
||||
:xf (remove #(= (:session-id %) session-id)))]
|
||||
|
||||
(let [msgbus (:msgbus cfg)
|
||||
conn-id (::ws/id @wsp)
|
||||
profile-id (::profile-id @wsp)
|
||||
session-id (::session-id @wsp)
|
||||
output-ch (::ws/output-ch @wsp)
|
||||
;; Subscribe to the profile channel and forward all messages to websocket output
|
||||
;; channel (send them to the client).
|
||||
(swap! state assoc ::profile-subscription {:channel ch})
|
||||
|
||||
xform (remove #(= (:session-id %) session-id))
|
||||
channel (a/chan (a/dropping-buffer 16) xform)]
|
||||
;; Forward the subscription messages directly to the websocket output channel
|
||||
(sp/pipe ch output-ch false)
|
||||
|
||||
(l/trace :fn "handle-message" :event "connect" :conn-id conn-id)
|
||||
;; Subscribe to the profile topic on msgbus/redis
|
||||
(mbus/sub! msgbus :topic profile-id :chan ch)))
|
||||
|
||||
;; Subscribe to the profile channel and forward all messages to
|
||||
;; websocket output channel (send them to the client).
|
||||
(swap! wsp assoc ::profile-subscription channel)
|
||||
(a/pipe channel output-ch false)
|
||||
(mbus/sub! msgbus :topic profile-id :chan channel)))
|
||||
(defmethod handle-message :close
|
||||
[{:keys [::mbus/msgbus]} {:keys [::ws/id ::ws/state ::profile-id ::session-id]} _]
|
||||
(l/trace :fn "handle-message" :event "close" :conn-id id)
|
||||
(let [psub (::profile-subscription @state)
|
||||
fsub (::file-subscription @state)
|
||||
tsub (::team-subscription @state)
|
||||
msg {:type :disconnect
|
||||
:subs-id profile-id
|
||||
:profile-id profile-id
|
||||
:session-id session-id}]
|
||||
|
||||
(defmethod handle-message :disconnect
|
||||
[cfg wsp _]
|
||||
(let [msgbus (:msgbus cfg)
|
||||
conn-id (::ws/id @wsp)
|
||||
profile-id (::profile-id @wsp)
|
||||
session-id (::session-id @wsp)
|
||||
profile-ch (::profile-subscription @wsp)
|
||||
fsub (::file-subscription @wsp)
|
||||
tsub (::team-subscription @wsp)
|
||||
;; Close profile subscription if exists
|
||||
(when-let [ch (:channel psub)]
|
||||
(sp/close! ch)
|
||||
(mbus/purge! msgbus [ch]))
|
||||
|
||||
message {:type :disconnect
|
||||
:subs-id profile-id
|
||||
:profile-id profile-id
|
||||
:session-id session-id}]
|
||||
|
||||
(l/trace :fn "handle-message"
|
||||
:event :disconnect
|
||||
:conn-id conn-id)
|
||||
|
||||
(a/go
|
||||
;; Close the main profile subscription
|
||||
(a/close! profile-ch)
|
||||
(a/<! (mbus/purge! msgbus [profile-ch]))
|
||||
|
||||
;; Close tram subscription if exists
|
||||
(when-let [channel (:channel tsub)]
|
||||
(a/close! channel)
|
||||
(a/<! (mbus/purge! msgbus channel)))
|
||||
;; Close team subscription if exists
|
||||
(when-let [ch (:channel tsub)]
|
||||
(sp/close! ch)
|
||||
(mbus/purge! msgbus [ch]))
|
||||
|
||||
;; Close file subscription if exists
|
||||
(when-let [{:keys [topic channel]} fsub]
|
||||
(a/close! channel)
|
||||
(a/<! (mbus/purge! msgbus channel))
|
||||
(a/<! (mbus/pub! msgbus :topic topic :message message))))))
|
||||
(sp/close! channel)
|
||||
(mbus/purge! msgbus [channel])
|
||||
(mbus/pub! msgbus :topic topic :message msg))))
|
||||
|
||||
(defmethod handle-message :subscribe-team
|
||||
[cfg wsp {:keys [team-id] :as params}]
|
||||
(let [msgbus (:msgbus cfg)
|
||||
conn-id (::ws/id @wsp)
|
||||
session-id (::session-id @wsp)
|
||||
output-ch (::ws/output-ch @wsp)
|
||||
prev-subs (get @wsp ::team-subscription)
|
||||
xform (comp
|
||||
(remove #(= (:session-id %) session-id))
|
||||
(map #(assoc % :subs-id team-id)))
|
||||
[{:keys [::mbus/msgbus]} {:keys [::ws/id ::ws/state ::ws/output-ch ::session-id]} {:keys [team-id] :as params}]
|
||||
(l/trace :fn "handle-message" :event "subscribe-team" :team-id team-id :conn-id id)
|
||||
(let [prev-subs (get @state ::team-subscription)
|
||||
channel (sp/chan :buf (sp/dropping-buffer 64)
|
||||
:xf (comp
|
||||
(remove #(= (:session-id %) session-id))
|
||||
(map #(assoc % :subs-id team-id))))]
|
||||
|
||||
channel (a/chan (a/dropping-buffer 64) xform)]
|
||||
(sp/pipe channel output-ch false)
|
||||
(mbus/sub! msgbus :topic team-id :chan channel)
|
||||
|
||||
(l/trace :fn "handle-message"
|
||||
:event :subscribe-team
|
||||
:team-id team-id
|
||||
:conn-id conn-id)
|
||||
(let [subs {:team-id team-id :channel channel :topic team-id}]
|
||||
(swap! state assoc ::team-subscription subs))
|
||||
|
||||
(a/pipe channel output-ch false)
|
||||
;; Close previous subscription if exists
|
||||
(when-let [ch (:channel prev-subs)]
|
||||
(sp/close! ch)
|
||||
(mbus/purge! msgbus [ch]))))
|
||||
|
||||
(let [state {:team-id team-id :channel channel :topic team-id}]
|
||||
(swap! wsp assoc ::team-subscription state))
|
||||
|
||||
(a/go
|
||||
;; Close previous subscription if exists
|
||||
(when-let [channel (:channel prev-subs)]
|
||||
(a/close! channel)
|
||||
(a/<! (mbus/purge! msgbus channel))))
|
||||
|
||||
(a/go
|
||||
(a/<! (mbus/sub! msgbus :topic team-id :chan channel)))))
|
||||
|
||||
(defmethod handle-message :subscribe-file
|
||||
[cfg wsp {:keys [file-id] :as params}]
|
||||
(let [msgbus (:msgbus cfg)
|
||||
conn-id (::ws/id @wsp)
|
||||
profile-id (::profile-id @wsp)
|
||||
session-id (::session-id @wsp)
|
||||
output-ch (::ws/output-ch @wsp)
|
||||
prev-subs (::file-subscription @wsp)
|
||||
xform (comp (remove #(= (:session-id %) session-id))
|
||||
(map #(assoc % :subs-id file-id)))
|
||||
channel (a/chan (a/dropping-buffer 64) xform)]
|
||||
[{:keys [::mbus/msgbus]} {:keys [::ws/id ::ws/state ::ws/output-ch ::session-id ::profile-id]} {:keys [file-id] :as params}]
|
||||
(l/trace :fn "handle-message" :event "subscribe-file" :file-id file-id :conn-id id)
|
||||
(let [psub (::file-subscription @state)
|
||||
fch (sp/chan :buf (sp/dropping-buffer 64)
|
||||
:xf (comp (remove #(= (:session-id %) session-id))
|
||||
(map #(assoc % :subs-id file-id))))]
|
||||
|
||||
(l/trace :fn "handle-message"
|
||||
:event :subscribe-file
|
||||
:file-id file-id
|
||||
:conn-id conn-id)
|
||||
(let [subs {:file-id file-id :channel fch :topic file-id}]
|
||||
(swap! state assoc ::file-subscription subs))
|
||||
|
||||
(let [state {:file-id file-id :channel channel :topic file-id}]
|
||||
(swap! wsp assoc ::file-subscription state))
|
||||
;; Close previous subscription if exists
|
||||
(when-let [ch (:channel psub)]
|
||||
(sp/close! ch)
|
||||
(mbus/purge! msgbus [ch]))
|
||||
|
||||
(a/go
|
||||
;; Close previous subscription if exists
|
||||
(when-let [channel (:channel prev-subs)]
|
||||
(a/close! channel)
|
||||
(a/<! (mbus/purge! msgbus channel))))
|
||||
|
||||
;; 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
|
||||
(sp/go-loop []
|
||||
(when-let [{:keys [type] :as message} (sp/take! fch)]
|
||||
(sp/put! output-ch message)
|
||||
(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/<! (mbus/pub! msgbus :topic file-id :message message))))
|
||||
(a/>! output-ch message)
|
||||
(recur))))
|
||||
(mbus/pub! msgbus
|
||||
:topic file-id
|
||||
:message message)))
|
||||
(recur)))
|
||||
|
||||
(a/go
|
||||
;; Subscribe to file topic
|
||||
(a/<! (mbus/sub! msgbus :topic file-id :chan channel))
|
||||
;; Subscribe to file topic
|
||||
(mbus/sub! msgbus :topic file-id :chan fch)
|
||||
|
||||
;; Notifify the rest of participants of the new connection.
|
||||
(let [message {:type :join-file
|
||||
:file-id file-id
|
||||
:subs-id file-id
|
||||
:session-id session-id
|
||||
:profile-id profile-id}]
|
||||
(a/<! (mbus/pub! msgbus :topic file-id :message message))))))
|
||||
;; Notifify the rest of participants of the new connection.
|
||||
(let [message {:type :join-file
|
||||
:file-id file-id
|
||||
:subs-id file-id
|
||||
:session-id session-id
|
||||
:profile-id profile-id}]
|
||||
(mbus/pub! msgbus :topic file-id :message message))))
|
||||
|
||||
(defmethod handle-message :unsubscribe-file
|
||||
[cfg wsp {:keys [file-id] :as params}]
|
||||
(let [msgbus (:msgbus cfg)
|
||||
conn-id (::ws/id @wsp)
|
||||
session-id (::session-id @wsp)
|
||||
profile-id (::profile-id @wsp)
|
||||
subs (::file-subscription @wsp)
|
||||
[{:keys [::mbus/msgbus]} {:keys [::ws/id ::ws/state ::session-id ::profile-id]} {:keys [file-id] :as params}]
|
||||
(l/trace :fn "handle-message" :event "unsubscribe-file" :file-id file-id :conn-id id)
|
||||
|
||||
message {:type :leave-file
|
||||
:file-id file-id
|
||||
:session-id session-id
|
||||
:profile-id profile-id}]
|
||||
(let [subs (::file-subscription @state)
|
||||
message {:type :leave-file
|
||||
:file-id file-id
|
||||
:session-id session-id
|
||||
:profile-id profile-id}]
|
||||
|
||||
(l/trace :fn "handle-message"
|
||||
:event :unsubscribe-file
|
||||
:file-id file-id
|
||||
:conn-id conn-id)
|
||||
|
||||
(a/go
|
||||
(when (= (:file-id subs) file-id)
|
||||
(let [channel (:channel subs)]
|
||||
(a/close! channel)
|
||||
(a/<! (mbus/purge! msgbus channel))
|
||||
(a/<! (mbus/pub! msgbus :topic file-id :message message)))))))
|
||||
(when (= (:file-id subs) file-id)
|
||||
(mbus/pub! msgbus :topic file-id :message message)
|
||||
(let [ch (:channel subs)]
|
||||
(sp/close! ch)
|
||||
(mbus/purge! msgbus [ch])))))
|
||||
|
||||
(defmethod handle-message :keepalive
|
||||
[_ _ _]
|
||||
(l/trace :fn "handle-message" :event :keepalive)
|
||||
(a/go :nothing))
|
||||
(l/trace :fn "handle-message" :event :keepalive))
|
||||
|
||||
(defmethod handle-message :broadcast
|
||||
[{:keys [::mbus/msgbus]} {:keys [::ws/id ::session-id ::profile-id]} message]
|
||||
(l/trace :fn "handle-message" :event "broadcast" :conn-id id)
|
||||
(let [message (-> message
|
||||
(assoc :subs-id profile-id)
|
||||
(assoc :profile-id profile-id)
|
||||
(assoc :session-id session-id))]
|
||||
(mbus/pub! msgbus :topic profile-id :message message)))
|
||||
|
||||
(defmethod handle-message :pointer-update
|
||||
[cfg wsp {:keys [file-id] :as message}]
|
||||
(let [msgbus (:msgbus cfg)
|
||||
profile-id (::profile-id @wsp)
|
||||
session-id (::session-id @wsp)
|
||||
subs (::file-subscription @wsp)
|
||||
message (-> message
|
||||
(assoc :subs-id file-id)
|
||||
(assoc :profile-id profile-id)
|
||||
(assoc :session-id session-id))]
|
||||
(a/go
|
||||
;; Only allow receive pointer updates when active subscription
|
||||
(when subs
|
||||
(a/<! (mbus/pub! msgbus :topic file-id :message message))))))
|
||||
[{:keys [::mbus/msgbus]} {:keys [::ws/state ::session-id ::profile-id]} {:keys [file-id] :as message}]
|
||||
(when (::file-subscription @state)
|
||||
(let [message (-> message
|
||||
(assoc :subs-id file-id)
|
||||
(assoc :profile-id profile-id)
|
||||
(assoc :session-id session-id))]
|
||||
(mbus/pub! msgbus :topic file-id :message message))))
|
||||
|
||||
(defmethod handle-message :default
|
||||
[_ wsp message]
|
||||
(let [conn-id (::ws/id @wsp)]
|
||||
(l/warn :hint "received unexpected message"
|
||||
:message message
|
||||
:conn-id conn-id)
|
||||
(a/go :none)))
|
||||
[_ {:keys [::ws/id]} message]
|
||||
(l/warn :hint "received unexpected message"
|
||||
:message message
|
||||
:conn-id id))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; HTTP HANDLER
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(s/def ::msgbus ::mbus/msgbus)
|
||||
(s/def ::session-id ::us/uuid)
|
||||
(defn- on-connect
|
||||
[{:keys [::mtx/metrics]} {:keys [::ws/id] :as wsp}]
|
||||
(let [created-at (dt/now)]
|
||||
(l/trace :fn "on-connect" :conn-id id)
|
||||
(swap! state assoc id wsp)
|
||||
(mtx/run! metrics
|
||||
:id :websocket-active-connections
|
||||
:inc 1)
|
||||
|
||||
(assoc wsp ::ws/on-disconnect
|
||||
(fn []
|
||||
(l/trace :fn "on-disconnect" :conn-id id)
|
||||
(swap! state dissoc id)
|
||||
(mtx/run! metrics :id :websocket-active-connections :dec 1)
|
||||
(mtx/run! metrics
|
||||
:id :websocket-session-timing
|
||||
:val (/ (inst-ms (dt/diff created-at (dt/now))) 1000.0))))))
|
||||
|
||||
(defn- on-rcv-message
|
||||
[{:keys [::mtx/metrics ::profile-id ::session-id]} message]
|
||||
(mtx/run! metrics
|
||||
:id :websocket-messages-total
|
||||
:labels recv-labels
|
||||
:inc 1)
|
||||
(assoc message :profile-id profile-id :session-id session-id))
|
||||
|
||||
(defn- on-snd-message
|
||||
[{:keys [::mtx/metrics]} message]
|
||||
(mtx/run! metrics
|
||||
:id :websocket-messages-total
|
||||
:labels send-labels
|
||||
:inc 1)
|
||||
message)
|
||||
|
||||
|
||||
(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]))
|
||||
(defn- http-handler
|
||||
[cfg {:keys [params ::session/profile-id] :as request}]
|
||||
(let [{:keys [session-id]} (us/conform ::handler-params params)]
|
||||
(cond
|
||||
(not profile-id)
|
||||
(ex/raise :type :authentication
|
||||
:hint "Authentication required.")
|
||||
|
||||
(defmethod ig/init-key ::handler
|
||||
(not (yws/upgrade-request? request))
|
||||
(ex/raise :type :validation
|
||||
:code :websocket-request-expected
|
||||
:hint "this endpoint only accepts websocket connections")
|
||||
|
||||
:else
|
||||
(do
|
||||
(l/trace :hint "websocket request" :profile-id profile-id :session-id session-id)
|
||||
(->> (ws/handler
|
||||
::ws/on-rcv-message (partial on-rcv-message cfg)
|
||||
::ws/on-snd-message (partial on-snd-message cfg)
|
||||
::ws/on-connect (partial on-connect cfg)
|
||||
::ws/handler (partial handle-message cfg)
|
||||
::profile-id profile-id
|
||||
::session-id session-id)
|
||||
(yws/upgrade request))))))
|
||||
|
||||
(defmethod ig/pre-init-spec ::routes [_]
|
||||
(s/keys :req [::mbus/msgbus
|
||||
::mtx/metrics
|
||||
::db/pool
|
||||
::session/manager]))
|
||||
|
||||
(s/def ::routes vector?)
|
||||
|
||||
(defmethod ig/init-key ::routes
|
||||
[_ cfg]
|
||||
(fn [{:keys [profile-id params] :as req} respond raise]
|
||||
(let [{:keys [session-id]} (us/conform ::handler-params params)]
|
||||
(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
|
||||
(do
|
||||
(l/trace :hint "websocket request" :profile-id profile-id :session-id session-id)
|
||||
|
||||
(->> (ws/handler
|
||||
::ws/on-rcv-message (partial on-rcv-message cfg)
|
||||
::ws/on-snd-message (partial on-snd-message cfg)
|
||||
::ws/on-connect (partial on-connect cfg)
|
||||
::ws/handler (partial handle-message cfg)
|
||||
::profile-id profile-id
|
||||
::session-id session-id)
|
||||
(yws/upgrade req)
|
||||
(respond)))))))
|
||||
["/ws/notifications" {:middleware [[session/authz cfg]]
|
||||
:handler (partial http-handler cfg)
|
||||
:allowed-methods #{:get}}])
|
||||
|
||||
@@ -16,13 +16,16 @@
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.http.client :as http]
|
||||
[app.http :as-alias http]
|
||||
[app.http.access-token :as-alias actoken]
|
||||
[app.http.client :as http.client]
|
||||
[app.loggers.audit.tasks :as-alias tasks]
|
||||
[app.loggers.webhooks :as-alias webhooks]
|
||||
[app.main :as-alias main]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.retry :as rtry]
|
||||
[app.tokens :as tokens]
|
||||
[app.util.retry :as rtry]
|
||||
[app.util.services :as-alias sv]
|
||||
[app.util.time :as dt]
|
||||
[app.worker :as wrk]
|
||||
[clojure.spec.alpha :as s]
|
||||
@@ -92,6 +95,15 @@
|
||||
|
||||
;; --- SPECS
|
||||
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; COLLECTOR
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
;; Defines a service that collects the audit/activity log using
|
||||
;; internal database. Later this audit log can be transferred to
|
||||
;; an external storage and data cleared.
|
||||
|
||||
(s/def ::profile-id ::us/uuid)
|
||||
(s/def ::name ::us/string)
|
||||
(s/def ::type ::us/string)
|
||||
@@ -104,20 +116,13 @@
|
||||
(s/or :fn fn? :str string? :kw keyword?))
|
||||
|
||||
(s/def ::event
|
||||
(s/keys :req-un [::type ::name ::profile-id]
|
||||
:opt-un [::ip-addr ::props]
|
||||
:opt [::webhooks/event?
|
||||
(s/keys :req [::type ::name ::profile-id]
|
||||
:opt [::ip-addr
|
||||
::props
|
||||
::webhooks/event?
|
||||
::webhooks/batch-timeout
|
||||
::webhooks/batch-key]))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; COLLECTOR
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
;; Defines a service that collects the audit/activity log using
|
||||
;; internal database. Later this audit log can be transferred to
|
||||
;; an external storage and data cleared.
|
||||
|
||||
(s/def ::collector
|
||||
(s/keys :req [::wrk/executor ::db/pool]))
|
||||
|
||||
@@ -133,15 +138,64 @@
|
||||
:else
|
||||
cfg))
|
||||
|
||||
(defn prepare-event
|
||||
[cfg mdata params result]
|
||||
(let [resultm (meta result)
|
||||
request (-> params meta ::http/request)
|
||||
profile-id (or (::profile-id resultm)
|
||||
(:profile-id result)
|
||||
(::rpc/profile-id params)
|
||||
uuid/zero)
|
||||
|
||||
props (-> (or (::replace-props resultm)
|
||||
(-> params
|
||||
(merge (::props resultm))
|
||||
(dissoc :profile-id)
|
||||
(dissoc :type)))
|
||||
|
||||
(clean-props))
|
||||
|
||||
token-id (::actoken/id request)
|
||||
context (d/without-nils
|
||||
{:access-token-id (some-> token-id str)})]
|
||||
|
||||
{::type (or (::type resultm)
|
||||
(::rpc/type cfg))
|
||||
::name (or (::name resultm)
|
||||
(::sv/name mdata))
|
||||
::profile-id profile-id
|
||||
::ip-addr (some-> request parse-client-ip)
|
||||
::props props
|
||||
::context context
|
||||
|
||||
;; NOTE: for batch-key lookup we need the params as-is
|
||||
;; because the rpc api does not need to know the
|
||||
;; audit/webhook specific object layout.
|
||||
::rpc/params params
|
||||
|
||||
::webhooks/batch-key
|
||||
(or (::webhooks/batch-key mdata)
|
||||
(::webhooks/batch-key resultm))
|
||||
|
||||
::webhooks/batch-timeout
|
||||
(or (::webhooks/batch-timeout mdata)
|
||||
(::webhooks/batch-timeout resultm))
|
||||
|
||||
::webhooks/event?
|
||||
(or (::webhooks/event? mdata)
|
||||
(::webhooks/event? resultm)
|
||||
false)}))
|
||||
|
||||
(defn- handle-event!
|
||||
[conn-or-pool event]
|
||||
(us/verify! ::event event)
|
||||
(let [params {:id (uuid/next)
|
||||
:name (:name event)
|
||||
:type (:type event)
|
||||
:profile-id (:profile-id event)
|
||||
:ip-addr (:ip-addr event)
|
||||
:props (:props event)}]
|
||||
:name (::name event)
|
||||
:type (::type event)
|
||||
:profile-id (::profile-id event)
|
||||
:ip-addr (::ip-addr event)
|
||||
:context (::context event)
|
||||
:props (::props event)}]
|
||||
|
||||
(when (contains? cf/flags :audit-log)
|
||||
;; NOTE: this operation may cause primary key conflicts on inserts
|
||||
@@ -149,11 +203,13 @@
|
||||
;; this case we just retry the operation.
|
||||
(rtry/with-retry {::rtry/when rtry/conflict-exception?
|
||||
::rtry/max-retries 6
|
||||
::rtry/label "persist-audit-log-event"}
|
||||
::rtry/label "persist-audit-log"
|
||||
::db/conn (dm/check db/connection? conn-or-pool)}
|
||||
(let [now (dt/now)]
|
||||
(db/insert! conn-or-pool :audit-log
|
||||
(-> params
|
||||
(update :props db/tjson)
|
||||
(update :context db/tjson)
|
||||
(update :ip-addr db/inet)
|
||||
(assoc :created-at now)
|
||||
(assoc :tracked-at now)
|
||||
@@ -186,9 +242,8 @@
|
||||
|
||||
(defn submit!
|
||||
"Submit audit event to the collector."
|
||||
[{:keys [::wrk/executor] :as cfg} params]
|
||||
[cfg params]
|
||||
(let [conn (or (::db/conn cfg) (::db/pool cfg))]
|
||||
(us/assert! ::wrk/executor executor)
|
||||
(us/assert! ::db/pool-or-conn conn)
|
||||
(try
|
||||
(handle-event! conn (d/without-nils params))
|
||||
@@ -207,7 +262,7 @@
|
||||
(s/def ::tasks/uri ::us/string)
|
||||
|
||||
(defmethod ig/pre-init-spec ::tasks/archive-task [_]
|
||||
(s/keys :req [::db/pool ::main/props ::http/client]))
|
||||
(s/keys :req [::db/pool ::main/props ::http.client/client]))
|
||||
|
||||
(defmethod ig/init-key ::tasks/archive
|
||||
[_ cfg]
|
||||
@@ -231,7 +286,7 @@
|
||||
(if n
|
||||
(do
|
||||
(px/sleep 100)
|
||||
(recur (+ total n)))
|
||||
(recur (+ total ^long n)))
|
||||
(when (pos? total)
|
||||
(l/debug :hint "events archived" :total total)))))))))
|
||||
|
||||
@@ -281,7 +336,7 @@
|
||||
:method :post
|
||||
:headers headers
|
||||
:body body}
|
||||
resp (http/req! cfg params {:sync? true})]
|
||||
resp (http.client/req! cfg params {:sync? true})]
|
||||
(if (= (:status resp) 204)
|
||||
true
|
||||
(do
|
||||
|
||||
@@ -7,16 +7,18 @@
|
||||
(ns app.loggers.database
|
||||
"A specific logger impl that persists errors on the database."
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.logging :as l]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.common.pprint :as pp]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.spec :as us]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.loggers.zmq :as lzmq]
|
||||
[clojure.core.async :as a]
|
||||
[clojure.spec.alpha :as s]
|
||||
[cuerdas.core :as str]
|
||||
[integrant.core :as ig]
|
||||
[promesa.exec :as px]))
|
||||
[promesa.exec :as px]
|
||||
[promesa.exec.csp :as sp]))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; Error Listener
|
||||
@@ -27,73 +29,86 @@
|
||||
(defonce enabled (atom true))
|
||||
|
||||
(defn- persist-on-database!
|
||||
[{:keys [::db/pool] :as cfg} {:keys [id] :as event}]
|
||||
[pool id report]
|
||||
(when-not (db/read-only? pool)
|
||||
(db/insert! pool :server-error-report {:id id :content (db/tjson event)})))
|
||||
(db/insert! pool :server-error-report
|
||||
{:id id
|
||||
:version 3
|
||||
:content (db/tjson report)})))
|
||||
|
||||
(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 record->report
|
||||
[{:keys [::l/context ::l/message ::l/props ::l/logger ::l/level ::l/cause] :as record}]
|
||||
(us/assert! ::l/record record)
|
||||
|
||||
(defn parse-event
|
||||
[event]
|
||||
(-> (parse-event-data event)
|
||||
(assoc :hint (or (:hint event) (:message 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)))))
|
||||
(let [data (ex-data cause)]
|
||||
(merge
|
||||
{:context (-> context
|
||||
(assoc :tenant (cf/get :tenant))
|
||||
(assoc :host (cf/get :host))
|
||||
(assoc :public-uri (cf/get :public-uri))
|
||||
(assoc :version (:full cf/version))
|
||||
(assoc :logger-name logger)
|
||||
(assoc :logger-level level)
|
||||
(dissoc :params)
|
||||
(pp/pprint-str :width 200))
|
||||
|
||||
:props (pp/pprint-str props :width 200)
|
||||
:hint (or (ex-message cause) @message)
|
||||
:trace (ex/format-throwable cause :data? false :explain? false :header? false :summary? false)}
|
||||
|
||||
(when-let [params (:params context)]
|
||||
{:params (pp/pprint-str params :width 200)})
|
||||
|
||||
(when-let [data (some-> data (dissoc ::s/problems ::s/value ::s/spec ::sm/explain :hint))]
|
||||
{:data (pp/pprint-str data :width 200)})
|
||||
|
||||
(when-let [value (-> data ::sm/explain :value)]
|
||||
{:value (pp/pprint-str value :width 200)})
|
||||
|
||||
(when-let [explain (ex/explain data)]
|
||||
{:explain explain}))))
|
||||
|
||||
|
||||
(defn error-record?
|
||||
[{:keys [::l/level ::l/cause]}]
|
||||
(and (= :error level)
|
||||
(ex/exception? cause)))
|
||||
|
||||
(defn- handle-event
|
||||
[cfg event]
|
||||
[{:keys [::db/pool]} {:keys [::l/id] :as record}]
|
||||
(try
|
||||
(let [event (parse-event event)
|
||||
uri (cf/get :public-uri)]
|
||||
(let [uri (cf/get :public-uri)
|
||||
report (-> record record->report d/without-nils)]
|
||||
(l/debug :hint "registering error on database" :id id
|
||||
:uri (str uri "/dbg/error/" id))
|
||||
|
||||
(l/debug :hint "registering error on database" :id (:id event)
|
||||
:uri (str uri "/dbg/error/" (:id event)))
|
||||
|
||||
(persist-on-database! cfg event))
|
||||
(persist-on-database! pool id report))
|
||||
(catch Throwable cause
|
||||
(l/warn :hint "unexpected exception on database error logger" :cause cause))))
|
||||
|
||||
(defn- error-event?
|
||||
[event]
|
||||
(= "error" (:logger/level event)))
|
||||
|
||||
(defmethod ig/pre-init-spec ::reporter [_]
|
||||
(s/keys :req [::db/pool ::lzmq/receiver]))
|
||||
(s/keys :req [::db/pool]))
|
||||
|
||||
(defmethod ig/init-key ::reporter
|
||||
[_ {:keys [::lzmq/receiver] :as cfg}]
|
||||
(px/thread
|
||||
{:name "penpot/database-reporter"}
|
||||
(l/info :hint "initializing database error persistence")
|
||||
[_ cfg]
|
||||
(let [input (sp/chan :buf (sp/sliding-buffer 32)
|
||||
:xf (filter error-record?))]
|
||||
(add-watch l/log-record ::reporter #(sp/put! input %4))
|
||||
|
||||
(let [input (a/chan (a/sliding-buffer 5)
|
||||
(filter error-event?))]
|
||||
(px/thread {:name "penpot/database-reporter" :virtual true}
|
||||
(l/info :hint "initializing database error persistence")
|
||||
(try
|
||||
(lzmq/sub! receiver input)
|
||||
(loop []
|
||||
(when-let [msg (a/<!! input)]
|
||||
(handle-event cfg msg))
|
||||
(recur))
|
||||
|
||||
(when-let [record (sp/take! input)]
|
||||
(handle-event cfg record)
|
||||
(recur)))
|
||||
(catch InterruptedException _
|
||||
(l/debug :hint "reporter interrupted"))
|
||||
(catch Throwable cause
|
||||
(l/error :hint "unexpected error" :cause cause))
|
||||
(finally
|
||||
(a/close! input)
|
||||
(sp/close! input)
|
||||
(remove-watch l/log-record ::reporter)
|
||||
(l/info :hint "reporter terminated"))))))
|
||||
|
||||
(defmethod ig/halt-key! ::reporter
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC
|
||||
|
||||
(ns app.loggers.loki
|
||||
"A Loki integration."
|
||||
(:require
|
||||
[app.common.logging :as l]
|
||||
[app.config :as cf]
|
||||
[app.http.client :as http]
|
||||
[app.loggers.zmq :as lzmq]
|
||||
[app.util.json :as json]
|
||||
[clojure.core.async :as a]
|
||||
[clojure.spec.alpha :as s]
|
||||
[integrant.core :as ig]
|
||||
[promesa.exec :as px]))
|
||||
|
||||
(declare ^:private handle-event)
|
||||
|
||||
(defmethod ig/pre-init-spec ::reporter [_]
|
||||
(s/keys :req [::http/client
|
||||
::lzmq/receiver]))
|
||||
|
||||
(defmethod ig/init-key ::reporter
|
||||
[_ cfg]
|
||||
(when-let [uri (cf/get :loggers-loki-uri)]
|
||||
(px/thread
|
||||
{:name "penpot/loki-reporter"}
|
||||
(l/info :hint "reporter started" :uri uri)
|
||||
(let [input (a/chan (a/dropping-buffer 2048))
|
||||
cfg (assoc cfg ::uri uri)]
|
||||
|
||||
(try
|
||||
(lzmq/sub! (::lzmq/receiver cfg) input)
|
||||
(loop []
|
||||
(when-let [msg (a/<!! input)]
|
||||
(handle-event cfg msg)
|
||||
(recur)))
|
||||
|
||||
(catch InterruptedException _
|
||||
(l/debug :hint "reporter interrupted"))
|
||||
(catch Throwable cause
|
||||
(l/error :hint "unexpected exception"
|
||||
:cause cause))
|
||||
(finally
|
||||
(a/close! input)
|
||||
(l/info :hint "reporter terminated")))))))
|
||||
|
||||
(defmethod ig/halt-key! ::reporter
|
||||
[_ thread]
|
||||
(some-> thread px/interrupt!))
|
||||
|
||||
(defn- prepare-payload
|
||||
[event]
|
||||
(let [labels {:host (cf/get :host)
|
||||
:tenant (cf/get :tenant)
|
||||
:version (:full cf/version)
|
||||
: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 (:trace event)]
|
||||
(str "\n" error)))]]}]}))
|
||||
|
||||
(defn- make-request
|
||||
[{:keys [::uri] :as cfg} payload]
|
||||
(http/req! cfg
|
||||
{:uri uri
|
||||
:timeout 3000
|
||||
:method :post
|
||||
:headers {"content-type" "application/json"}
|
||||
:body (json/encode payload)}
|
||||
{:sync? true}))
|
||||
|
||||
(defn- handle-event
|
||||
[cfg event]
|
||||
(try
|
||||
(let [payload (prepare-payload event)
|
||||
response (make-request cfg payload)]
|
||||
(when-not (= 204 (:status 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,24 +7,35 @@
|
||||
(ns app.loggers.mattermost
|
||||
"A mattermost integration for error reporting."
|
||||
(:require
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.logging :as l]
|
||||
[app.common.spec :as us]
|
||||
[app.config :as cf]
|
||||
[app.http.client :as http]
|
||||
[app.loggers.database :as ldb]
|
||||
[app.loggers.zmq :as lzmq]
|
||||
[app.util.json :as json]
|
||||
[clojure.core.async :as a]
|
||||
[clojure.spec.alpha :as s]
|
||||
[integrant.core :as ig]
|
||||
[promesa.exec :as px]))
|
||||
[promesa.exec :as px]
|
||||
[promesa.exec.csp :as sp]))
|
||||
|
||||
(defonce enabled (atom true))
|
||||
|
||||
(defn- send-mattermost-notification!
|
||||
[cfg {:keys [host id public-uri] :as event}]
|
||||
(let [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")))
|
||||
[cfg {:keys [id public-uri] :as report}]
|
||||
(let [text (str "Exception: " public-uri "/dbg/error/" id " "
|
||||
(when-let [pid (:profile-id report)]
|
||||
(str "(pid: #uuid-" pid ")"))
|
||||
"\n"
|
||||
"```\n"
|
||||
"- host: `" (:host report) "`\n"
|
||||
"- tenant: `" (:tenant report) "`\n"
|
||||
"- version: `" (:version report) "`\n"
|
||||
"\n"
|
||||
"Trace:\n"
|
||||
(:trace report)
|
||||
"```")
|
||||
|
||||
resp (http/req! cfg
|
||||
{:uri (cf/get :error-report-webhook)
|
||||
:method :post
|
||||
@@ -36,32 +47,42 @@
|
||||
(l/warn :hint "error on sending data"
|
||||
:response (pr-str resp)))))
|
||||
|
||||
(defn record->report
|
||||
[{:keys [::l/context ::l/id ::l/cause] :as record}]
|
||||
(us/assert! ::l/record record)
|
||||
{:id id
|
||||
:tenant (cf/get :tenant)
|
||||
:host (cf/get :host)
|
||||
:public-uri (cf/get :public-uri)
|
||||
:version (:full cf/version)
|
||||
:profile-id (:profile-id context)
|
||||
:trace (ex/format-throwable cause :detail? false :header? false)})
|
||||
|
||||
(defn handle-event
|
||||
[cfg event]
|
||||
[cfg record]
|
||||
(when @enabled
|
||||
(try
|
||||
(let [event (ldb/parse-event event)]
|
||||
(send-mattermost-notification! cfg event))
|
||||
(let [report (record->report record)]
|
||||
(send-mattermost-notification! cfg report))
|
||||
(catch Throwable cause
|
||||
(l/warn :hint "unhandled error"
|
||||
:cause cause)))))
|
||||
(l/warn :hint "unhandled error" :cause cause)))))
|
||||
|
||||
(defmethod ig/pre-init-spec ::reporter [_]
|
||||
(s/keys :req [::http/client
|
||||
::lzmq/receiver]))
|
||||
(s/keys :req [::http/client]))
|
||||
|
||||
(defmethod ig/init-key ::reporter
|
||||
[_ cfg]
|
||||
(when-let [uri (cf/get :error-report-webhook)]
|
||||
(px/thread
|
||||
{:name "penpot/mattermost-reporter"}
|
||||
(l/info :msg "initializing error reporter" :uri uri)
|
||||
(let [input (a/chan (a/sliding-buffer 128)
|
||||
(filter #(= (:logger/level %) "error")))]
|
||||
{:name "penpot/mattermost-reporter"
|
||||
:virtual true}
|
||||
(l/info :hint "initializing error reporter" :uri uri)
|
||||
(let [input (sp/chan :buf (sp/sliding-buffer 128)
|
||||
:xf (filter ldb/error-record?))]
|
||||
(add-watch l/log-record ::reporter #(sp/put! input %4))
|
||||
(try
|
||||
(lzmq/sub! (::lzmq/receiver cfg) input)
|
||||
(loop []
|
||||
(when-let [msg (a/<!! input)]
|
||||
(when-let [msg (sp/take! input)]
|
||||
(handle-event cfg msg)
|
||||
(recur)))
|
||||
(catch InterruptedException _
|
||||
@@ -69,7 +90,8 @@
|
||||
(catch Throwable cause
|
||||
(l/error :hint "unexpected error" :cause cause))
|
||||
(finally
|
||||
(a/close! input)
|
||||
(sp/close! input)
|
||||
(remove-watch l/log-record ::reporter)
|
||||
(l/info :hint "reporter terminated")))))))
|
||||
|
||||
(defmethod ig/halt-key! ::reporter
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
" where id=?")
|
||||
err
|
||||
(:id whook)]
|
||||
res (db/exec-one! pool sql {:return-keys true})]
|
||||
res (db/exec-one! pool sql {::db/return-keys? true})]
|
||||
(when (>= (:error-count res) max-errors)
|
||||
(db/update! pool :webhook {:is-active false} {:id (:id whook)})))
|
||||
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC
|
||||
|
||||
(ns app.loggers.zmq
|
||||
"A generic ZMQ listener."
|
||||
(:require
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.logging :as l]
|
||||
[app.config :as cf]
|
||||
[app.loggers.zmq.receiver :as-alias receiver]
|
||||
[app.util.json :as json]
|
||||
[app.util.time :as dt]
|
||||
[clojure.core.async :as a]
|
||||
[clojure.spec.alpha :as s]
|
||||
[cuerdas.core :as str]
|
||||
[integrant.core :as ig]
|
||||
[promesa.exec :as px])
|
||||
(:import
|
||||
org.zeromq.SocketType
|
||||
org.zeromq.ZMQ$Socket
|
||||
org.zeromq.ZContext))
|
||||
|
||||
(declare prepare)
|
||||
(declare start-rcv-loop)
|
||||
|
||||
(defmethod ig/init-key ::receiver
|
||||
[_ cfg]
|
||||
(let [uri (cf/get :loggers-zmq-uri)
|
||||
buffer (a/chan 1)
|
||||
output (a/chan 1 (comp (filter map?)
|
||||
(keep prepare)))
|
||||
mult (a/mult output)
|
||||
thread (when uri
|
||||
(px/thread
|
||||
{:name "penpot/zmq-receiver"
|
||||
:daemon false}
|
||||
(l/info :hint "receiver started")
|
||||
(try
|
||||
(start-rcv-loop buffer uri)
|
||||
(catch InterruptedException _
|
||||
(l/debug :hint "receiver interrupted"))
|
||||
(catch java.lang.IllegalStateException cause
|
||||
(if (= "errno 4" (ex-message cause))
|
||||
(l/debug :hint "receiver interrupted")
|
||||
(l/error :hint "unhandled error" :cause cause)))
|
||||
(catch Throwable cause
|
||||
(l/error :hint "unhandled error" :cause cause))
|
||||
(finally
|
||||
(l/info :hint "receiver terminated")))))]
|
||||
|
||||
(a/pipe buffer output)
|
||||
(-> cfg
|
||||
(assoc ::receiver/mult mult)
|
||||
(assoc ::receiver/thread thread)
|
||||
(assoc ::receiver/output output)
|
||||
(assoc ::receiver/buffer buffer))))
|
||||
|
||||
(s/def ::receiver/mult some?)
|
||||
(s/def ::receiver/thread #(instance? Thread %))
|
||||
(s/def ::receiver/output some?)
|
||||
(s/def ::receiver/buffer some?)
|
||||
(s/def ::receiver
|
||||
(s/keys :req [::receiver/mult
|
||||
::receiver/thread
|
||||
::receiver/output
|
||||
::receiver/buffer]))
|
||||
|
||||
(defn sub!
|
||||
[{:keys [::receiver/mult]} ch]
|
||||
(a/tap mult ch))
|
||||
|
||||
(defmethod ig/halt-key! ::receiver
|
||||
[_ {:keys [::receiver/buffer ::receiver/thread]}]
|
||||
(some-> thread px/interrupt!)
|
||||
(some-> buffer a/close!))
|
||||
|
||||
(def ^:private json-mapper
|
||||
(json/mapper
|
||||
{:encode-key-fn str/camel
|
||||
:decode-key-fn (comp keyword str/kebab)}))
|
||||
|
||||
(defn- start-rcv-loop
|
||||
[output endpoint]
|
||||
(let [zctx (ZContext. 1)
|
||||
socket (.. zctx (createSocket SocketType/SUB))]
|
||||
(try
|
||||
(.. socket (connect ^String endpoint))
|
||||
(.. socket (subscribe ""))
|
||||
(.. socket (setReceiveTimeOut 5000))
|
||||
(loop []
|
||||
(let [msg (.recv ^ZMQ$Socket socket)
|
||||
msg (ex/ignoring (json/decode msg json-mapper))
|
||||
msg (if (nil? msg) :empty msg)]
|
||||
(when (a/>!! output msg)
|
||||
(recur))))
|
||||
|
||||
(finally
|
||||
(.close ^java.lang.AutoCloseable socket)
|
||||
(.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]
|
||||
(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)))
|
||||
@@ -12,20 +12,33 @@
|
||||
[app.common.logging :as l]
|
||||
[app.config :as cf]
|
||||
[app.db :as-alias db]
|
||||
[app.email :as-alias email]
|
||||
[app.http :as-alias http]
|
||||
[app.http.assets :as-alias http.assets]
|
||||
[app.http.awsns :as http.awsns]
|
||||
[app.http.client :as-alias http.client]
|
||||
[app.http.session :as-alias http.session]
|
||||
[app.http.debug :as-alias http.debug]
|
||||
[app.http.session :as-alias session]
|
||||
[app.http.session.tasks :as-alias session.tasks]
|
||||
[app.http.websocket :as http.ws]
|
||||
[app.loggers.audit.tasks :as-alias audit.tasks]
|
||||
[app.loggers.webhooks :as-alias webhooks]
|
||||
[app.loggers.zmq :as-alias lzmq]
|
||||
[app.metrics :as-alias mtx]
|
||||
[app.metrics.definition :as-alias mdef]
|
||||
[app.msgbus :as-alias mbus]
|
||||
[app.redis :as-alias rds]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.doc :as-alias rpc.doc]
|
||||
[app.setup :as-alias setup]
|
||||
[app.srepl :as-alias srepl]
|
||||
[app.storage :as-alias sto]
|
||||
[app.storage.fs :as-alias sto.fs]
|
||||
[app.storage.s3 :as-alias sto.s3]
|
||||
[app.util.time :as dt]
|
||||
[app.worker :as-alias wrk]
|
||||
[cuerdas.core :as str]
|
||||
[integrant.core :as ig])
|
||||
[integrant.core :as ig]
|
||||
[promesa.exec :as px])
|
||||
(:gen-class))
|
||||
|
||||
(def default-metrics
|
||||
@@ -90,15 +103,15 @@
|
||||
::mdef/labels ["name"]
|
||||
::mdef/type :summary}
|
||||
|
||||
:rpc-climit-queue-size
|
||||
{::mdef/name "penpot_rpc_climit_queue_size"
|
||||
::mdef/help "Current number of queued submissions on the CLIMIT."
|
||||
:rpc-climit-queue
|
||||
{::mdef/name "penpot_rpc_climit_queue"
|
||||
::mdef/help "Current number of queued submissions."
|
||||
::mdef/labels ["name"]
|
||||
::mdef/type :gauge}
|
||||
|
||||
:rpc-climit-concurrency
|
||||
{::mdef/name "penpot_rpc_climit_concurrency"
|
||||
::mdef/help "Current number of used concurrency capacity on the CLIMIT"
|
||||
:rpc-climit-permits
|
||||
{::mdef/name "penpot_rpc_climit_permits"
|
||||
::mdef/help "Current number of available permits"
|
||||
::mdef/labels ["name"]
|
||||
::mdef/type :gauge}
|
||||
|
||||
@@ -152,22 +165,18 @@
|
||||
|
||||
(def system-config
|
||||
{::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 ::mtx/metrics)
|
||||
:migrations (ig/ref :app.migrations/all)
|
||||
:name :main
|
||||
:min-size (cf/get :database-min-pool-size 0)
|
||||
:max-size (cf/get :database-max-pool-size 60)}
|
||||
{::db/uri (cf/get :database-uri)
|
||||
::db/username (cf/get :database-username)
|
||||
::db/password (cf/get :database-password)
|
||||
::db/read-only? (cf/get :database-readonly false)
|
||||
::db/min-size (cf/get :database-min-pool-size 0)
|
||||
::db/max-size (cf/get :database-max-pool-size 60)
|
||||
::mtx/metrics (ig/ref ::mtx/metrics)}
|
||||
|
||||
;; Default thread pool for IO operations
|
||||
::wrk/executor
|
||||
{::wrk/parallelism (cf/get :default-executor-parallelism 100)}
|
||||
|
||||
::wrk/scheduled-executor
|
||||
{::wrk/parallelism (cf/get :scheduled-executor-parallelism 20)}
|
||||
{::wrk/parallelism (cf/get :default-executor-parallelism
|
||||
(+ 3 (* (px/get-available-processors) 3)))}
|
||||
|
||||
::wrk/monitor
|
||||
{::mtx/metrics (ig/ref ::mtx/metrics)
|
||||
@@ -175,62 +184,56 @@
|
||||
::wrk/executor (ig/ref ::wrk/executor)}
|
||||
|
||||
:app.migrations/migrations
|
||||
{}
|
||||
{::db/pool (ig/ref ::db/pool)}
|
||||
|
||||
::mtx/metrics
|
||||
{:default default-metrics}
|
||||
|
||||
:app.migrations/all
|
||||
{:main (ig/ref :app.migrations/migrations)}
|
||||
::mtx/routes
|
||||
{::mtx/metrics (ig/ref ::mtx/metrics)}
|
||||
|
||||
::rds/redis
|
||||
{::rds/uri (cf/get :redis-uri)
|
||||
::mtx/metrics (ig/ref ::mtx/metrics)}
|
||||
{::rds/uri (cf/get :redis-uri)
|
||||
::mtx/metrics (ig/ref ::mtx/metrics)
|
||||
::wrk/executor (ig/ref ::wrk/executor)}
|
||||
|
||||
:app.msgbus/msgbus
|
||||
{:backend (cf/get :msgbus-backend :redis)
|
||||
:executor (ig/ref ::wrk/executor)
|
||||
:redis (ig/ref ::rds/redis)}
|
||||
::mbus/msgbus
|
||||
{::wrk/executor (ig/ref ::wrk/executor)
|
||||
::rds/redis (ig/ref ::rds/redis)}
|
||||
|
||||
:app.storage.tmp/cleaner
|
||||
{::wrk/executor (ig/ref ::wrk/executor)
|
||||
::wrk/scheduled-executor (ig/ref ::wrk/scheduled-executor)}
|
||||
{::wrk/executor (ig/ref ::wrk/executor)}
|
||||
|
||||
::sto/gc-deleted-task
|
||||
{:pool (ig/ref ::db/pool)
|
||||
:storage (ig/ref ::sto/storage)
|
||||
:executor (ig/ref ::wrk/executor)}
|
||||
{::db/pool (ig/ref ::db/pool)
|
||||
::sto/storage (ig/ref ::sto/storage)}
|
||||
|
||||
::sto/gc-touched-task
|
||||
{:pool (ig/ref ::db/pool)}
|
||||
{::db/pool (ig/ref ::db/pool)}
|
||||
|
||||
::http.client/client
|
||||
{::wrk/executor (ig/ref ::wrk/executor)}
|
||||
|
||||
:app.http.session/manager
|
||||
{::db/pool (ig/ref ::db/pool)
|
||||
::wrk/executor (ig/ref ::wrk/executor)
|
||||
::props (ig/ref :app.setup/props)}
|
||||
::session/manager
|
||||
{::db/pool (ig/ref ::db/pool)}
|
||||
|
||||
:app.http.session/gc-task
|
||||
{:pool (ig/ref ::db/pool)
|
||||
:max-age (cf/get :auth-token-cookie-max-age)}
|
||||
::session.tasks/gc
|
||||
{::db/pool (ig/ref ::db/pool)}
|
||||
|
||||
:app.http.awsns/handler
|
||||
{::props (ig/ref :app.setup/props)
|
||||
::http.awsns/routes
|
||||
{::props (ig/ref ::setup/props)
|
||||
::db/pool (ig/ref ::db/pool)
|
||||
::http.client/client (ig/ref ::http.client/client)
|
||||
::wrk/executor (ig/ref ::wrk/executor)}
|
||||
|
||||
:app.http/server
|
||||
{:port (cf/get :http-server-port)
|
||||
:host (cf/get :http-server-host)
|
||||
:router (ig/ref :app.http/router)
|
||||
:metrics (ig/ref ::mtx/metrics)
|
||||
:executor (ig/ref ::wrk/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)}
|
||||
::http/server
|
||||
{::http/port (cf/get :http-server-port)
|
||||
::http/host (cf/get :http-server-host)
|
||||
::http/router (ig/ref ::http/router)
|
||||
::wrk/executor (ig/ref ::wrk/executor)
|
||||
::http/io-threads (cf/get :http-server-io-threads)
|
||||
::http/max-body-size (cf/get :http-server-max-body-size)
|
||||
::http/max-multipart-body-size (cf/get :http-server-max-multipart-body-size)}
|
||||
|
||||
::ldap/provider
|
||||
{:host (cf/get :ldap-host)
|
||||
@@ -259,98 +262,87 @@
|
||||
{::http.client/client (ig/ref ::http.client/client)}
|
||||
|
||||
::oidc/routes
|
||||
{::http.client/client (ig/ref ::http.client/client)
|
||||
::db/pool (ig/ref ::db/pool)
|
||||
::props (ig/ref :app.setup/props)
|
||||
::wrk/executor (ig/ref ::wrk/executor)
|
||||
::oidc/providers {:google (ig/ref ::oidc.providers/google)
|
||||
:github (ig/ref ::oidc.providers/github)
|
||||
:gitlab (ig/ref ::oidc.providers/gitlab)
|
||||
:oidc (ig/ref ::oidc.providers/generic)}
|
||||
::http.session/session (ig/ref :app.http.session/manager)}
|
||||
{::http.client/client (ig/ref ::http.client/client)
|
||||
::db/pool (ig/ref ::db/pool)
|
||||
::props (ig/ref ::setup/props)
|
||||
::oidc/providers {:google (ig/ref ::oidc.providers/google)
|
||||
:github (ig/ref ::oidc.providers/github)
|
||||
:gitlab (ig/ref ::oidc.providers/gitlab)
|
||||
:oidc (ig/ref ::oidc.providers/generic)}
|
||||
::session/manager (ig/ref ::session/manager)}
|
||||
|
||||
;; TODO: revisit the dependencies of this service, looks they are too much unused of them
|
||||
:app.http/router
|
||||
{:assets (ig/ref :app.http.assets/handlers)
|
||||
:feedback (ig/ref :app.http.feedback/handler)
|
||||
:session (ig/ref :app.http.session/manager)
|
||||
:awsns-handler (ig/ref :app.http.awsns/handler)
|
||||
:debug-routes (ig/ref :app.http.debug/routes)
|
||||
:oidc-routes (ig/ref ::oidc/routes)
|
||||
:ws (ig/ref :app.http.websocket/handler)
|
||||
:metrics (ig/ref ::mtx/metrics)
|
||||
:public-uri (cf/get :public-uri)
|
||||
:storage (ig/ref ::sto/storage)
|
||||
:rpc-routes (ig/ref :app.rpc/routes)
|
||||
:doc-routes (ig/ref :app.rpc.doc/routes)
|
||||
:executor (ig/ref ::wrk/executor)}
|
||||
{::session/manager (ig/ref ::session/manager)
|
||||
::db/pool (ig/ref ::db/pool)
|
||||
::rpc/routes (ig/ref ::rpc/routes)
|
||||
::rpc.doc/routes (ig/ref ::rpc.doc/routes)
|
||||
::props (ig/ref ::setup/props)
|
||||
::mtx/routes (ig/ref ::mtx/routes)
|
||||
::oidc/routes (ig/ref ::oidc/routes)
|
||||
::http.debug/routes (ig/ref ::http.debug/routes)
|
||||
::http.assets/routes (ig/ref ::http.assets/routes)
|
||||
::http.ws/routes (ig/ref ::http.ws/routes)
|
||||
::http.awsns/routes (ig/ref ::http.awsns/routes)}
|
||||
|
||||
:app.http.debug/routes
|
||||
{:pool (ig/ref ::db/pool)
|
||||
:executor (ig/ref ::wrk/executor)
|
||||
:storage (ig/ref ::sto/storage)
|
||||
:session (ig/ref :app.http.session/manager)
|
||||
{::db/pool (ig/ref ::db/pool)
|
||||
::wrk/executor (ig/ref ::wrk/executor)
|
||||
::session/manager (ig/ref ::session/manager)
|
||||
::sto/storage (ig/ref ::sto/storage)}
|
||||
|
||||
::db/pool (ig/ref ::db/pool)
|
||||
::wrk/executor (ig/ref ::wrk/executor)
|
||||
::sto/storage (ig/ref ::sto/storage)}
|
||||
::http.ws/routes
|
||||
{::db/pool (ig/ref ::db/pool)
|
||||
::mtx/metrics (ig/ref ::mtx/metrics)
|
||||
::mbus/msgbus (ig/ref ::mbus/msgbus)
|
||||
::session/manager (ig/ref ::session/manager)}
|
||||
|
||||
:app.http.websocket/handler
|
||||
{:pool (ig/ref ::db/pool)
|
||||
:metrics (ig/ref ::mtx/metrics)
|
||||
:msgbus (ig/ref :app.msgbus/msgbus)}
|
||||
|
||||
:app.http.assets/handlers
|
||||
{:metrics (ig/ref ::mtx/metrics)
|
||||
:assets-path (cf/get :assets-path)
|
||||
:storage (ig/ref ::sto/storage)
|
||||
:executor (ig/ref ::wrk/executor)
|
||||
:cache-max-age (dt/duration {:hours 24})
|
||||
:signature-max-age (dt/duration {:hours 24 :minutes 5})}
|
||||
|
||||
:app.http.feedback/handler
|
||||
{:pool (ig/ref ::db/pool)
|
||||
:executor (ig/ref ::wrk/executor)}
|
||||
:app.http.assets/routes
|
||||
{::http.assets/path (cf/get :assets-path)
|
||||
::http.assets/cache-max-age (dt/duration {:hours 24})
|
||||
::http.assets/cache-max-agesignature-max-age (dt/duration {:hours 24 :minutes 5})
|
||||
::sto/storage (ig/ref ::sto/storage)
|
||||
::wrk/executor (ig/ref ::wrk/executor)}
|
||||
|
||||
:app.rpc/climit
|
||||
{:metrics (ig/ref ::mtx/metrics)
|
||||
:executor (ig/ref ::wrk/executor)}
|
||||
{::mtx/metrics (ig/ref ::mtx/metrics)
|
||||
::wrk/executor (ig/ref ::wrk/executor)}
|
||||
|
||||
:app.rpc/rlimit
|
||||
{:executor (ig/ref ::wrk/executor)
|
||||
:scheduled-executor (ig/ref ::wrk/scheduled-executor)}
|
||||
{::wrk/executor (ig/ref ::wrk/executor)}
|
||||
|
||||
:app.rpc/methods
|
||||
{::http.client/client (ig/ref ::http.client/client)
|
||||
::db/pool (ig/ref ::db/pool)
|
||||
::wrk/executor (ig/ref ::wrk/executor)
|
||||
::props (ig/ref :app.setup/props)
|
||||
::session/manager (ig/ref ::session/manager)
|
||||
::ldap/provider (ig/ref ::ldap/provider)
|
||||
::sto/storage (ig/ref ::sto/storage)
|
||||
::mtx/metrics (ig/ref ::mtx/metrics)
|
||||
::mbus/msgbus (ig/ref ::mbus/msgbus)
|
||||
::rds/redis (ig/ref ::rds/redis)
|
||||
|
||||
::rpc/climit (ig/ref ::rpc/climit)
|
||||
::rpc/rlimit (ig/ref ::rpc/rlimit)
|
||||
::setup/templates (ig/ref ::setup/templates)
|
||||
::props (ig/ref ::setup/props)
|
||||
|
||||
:pool (ig/ref ::db/pool)
|
||||
:session (ig/ref :app.http.session/manager)
|
||||
:sprops (ig/ref :app.setup/props)
|
||||
:metrics (ig/ref ::mtx/metrics)
|
||||
:storage (ig/ref ::sto/storage)
|
||||
:msgbus (ig/ref :app.msgbus/msgbus)
|
||||
:public-uri (cf/get :public-uri)
|
||||
:redis (ig/ref ::rds/redis)
|
||||
:http-client (ig/ref ::http.client/client)
|
||||
:climit (ig/ref :app.rpc/climit)
|
||||
:rlimit (ig/ref :app.rpc/rlimit)
|
||||
:executor (ig/ref ::wrk/executor)
|
||||
:templates (ig/ref :app.setup/builtin-templates)
|
||||
}
|
||||
|
||||
:app.rpc.doc/routes
|
||||
{:methods (ig/ref :app.rpc/methods)}
|
||||
|
||||
:app.rpc/routes
|
||||
{:methods (ig/ref :app.rpc/methods)}
|
||||
{::rpc/methods (ig/ref :app.rpc/methods)
|
||||
::db/pool (ig/ref ::db/pool)
|
||||
::wrk/executor (ig/ref ::wrk/executor)
|
||||
::session/manager (ig/ref ::session/manager)
|
||||
::props (ig/ref ::setup/props)}
|
||||
|
||||
::wrk/registry
|
||||
{:metrics (ig/ref ::mtx/metrics)
|
||||
:tasks
|
||||
{:sendmail (ig/ref :app.emails/handler)
|
||||
{::mtx/metrics (ig/ref ::mtx/metrics)
|
||||
::wrk/tasks
|
||||
{:sendmail (ig/ref ::email/handler)
|
||||
:objects-gc (ig/ref :app.tasks.objects-gc/handler)
|
||||
:file-gc (ig/ref :app.tasks.file-gc/handler)
|
||||
:file-xlog-gc (ig/ref :app.tasks.file-xlog-gc/handler)
|
||||
@@ -358,7 +350,7 @@
|
||||
:storage-gc-touched (ig/ref ::sto/gc-touched-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)
|
||||
:session-gc (ig/ref ::session.tasks/gc)
|
||||
:audit-log-archive (ig/ref ::audit.tasks/archive)
|
||||
:audit-log-gc (ig/ref ::audit.tasks/gc)
|
||||
|
||||
@@ -367,60 +359,59 @@
|
||||
:run-webhook
|
||||
(ig/ref ::webhooks/run-webhook-handler)}}
|
||||
|
||||
::email/sendmail
|
||||
{::email/host (cf/get :smtp-host)
|
||||
::email/port (cf/get :smtp-port)
|
||||
::email/ssl (cf/get :smtp-ssl)
|
||||
::email/tls (cf/get :smtp-tls)
|
||||
::email/username (cf/get :smtp-username)
|
||||
::email/password (cf/get :smtp-password)
|
||||
::email/default-reply-to (cf/get :smtp-default-reply-to)
|
||||
::email/default-from (cf/get :smtp-default-from)}
|
||||
|
||||
:app.emails/sendmail
|
||||
{:host (cf/get :smtp-host)
|
||||
:port (cf/get :smtp-port)
|
||||
:ssl (cf/get :smtp-ssl)
|
||||
:tls (cf/get :smtp-tls)
|
||||
:username (cf/get :smtp-username)
|
||||
:password (cf/get :smtp-password)
|
||||
:default-reply-to (cf/get :smtp-default-reply-to)
|
||||
:default-from (cf/get :smtp-default-from)}
|
||||
|
||||
:app.emails/handler
|
||||
{:sendmail (ig/ref :app.emails/sendmail)
|
||||
:metrics (ig/ref ::mtx/metrics)}
|
||||
::email/handler
|
||||
{::email/sendmail (ig/ref ::email/sendmail)
|
||||
::mtx/metrics (ig/ref ::mtx/metrics)}
|
||||
|
||||
:app.tasks.tasks-gc/handler
|
||||
{:pool (ig/ref ::db/pool)
|
||||
:max-age cf/deletion-delay}
|
||||
{::db/pool (ig/ref ::db/pool)}
|
||||
|
||||
:app.tasks.objects-gc/handler
|
||||
{::db/pool (ig/ref ::db/pool)
|
||||
::sto/storage (ig/ref ::sto/storage)}
|
||||
|
||||
:app.tasks.file-gc/handler
|
||||
{:pool (ig/ref ::db/pool)}
|
||||
{::db/pool (ig/ref ::db/pool)
|
||||
::sto/storage (ig/ref ::sto/storage)}
|
||||
|
||||
:app.tasks.file-xlog-gc/handler
|
||||
{:pool (ig/ref ::db/pool)}
|
||||
{::db/pool (ig/ref ::db/pool)}
|
||||
|
||||
:app.tasks.telemetry/handler
|
||||
{::db/pool (ig/ref ::db/pool)
|
||||
::http.client/client (ig/ref ::http.client/client)
|
||||
::props (ig/ref :app.setup/props)}
|
||||
::props (ig/ref ::setup/props)}
|
||||
|
||||
[::srepl/urepl ::srepl/server]
|
||||
{:port (cf/get :urepl-port 6062)
|
||||
:host (cf/get :urepl-host "localhost")}
|
||||
{::srepl/port (cf/get :urepl-port 6062)
|
||||
::srepl/host (cf/get :urepl-host "localhost")}
|
||||
|
||||
[::srepl/prepl ::srepl/server]
|
||||
{:port (cf/get :prepl-port 6063)
|
||||
:host (cf/get :prepl-host "localhost")}
|
||||
{::srepl/port (cf/get :prepl-port 6063)
|
||||
::srepl/host (cf/get :prepl-host "localhost")}
|
||||
|
||||
:app.setup/builtin-templates
|
||||
{::http.client/client (ig/ref ::http.client/client)}
|
||||
::setup/templates {}
|
||||
|
||||
:app.setup/props
|
||||
{:pool (ig/ref ::db/pool)
|
||||
:key (cf/get :secret-key)}
|
||||
::setup/props
|
||||
{::db/pool (ig/ref ::db/pool)
|
||||
::key (cf/get :secret-key)
|
||||
|
||||
::lzmq/receiver
|
||||
{}
|
||||
;; NOTE: this dependency is only necessary for proper initialization ordering, props
|
||||
;; module requires the migrations to run before initialize.
|
||||
::migrations (ig/ref :app.migrations/migrations)}
|
||||
|
||||
::audit.tasks/archive
|
||||
{::props (ig/ref :app.setup/props)
|
||||
{::props (ig/ref ::setup/props)
|
||||
::db/pool (ig/ref ::db/pool)
|
||||
::http.client/client (ig/ref ::http.client/client)}
|
||||
|
||||
@@ -435,45 +426,33 @@
|
||||
{::db/pool (ig/ref ::db/pool)
|
||||
::http.client/client (ig/ref ::http.client/client)}
|
||||
|
||||
:app.loggers.loki/reporter
|
||||
{::lzmq/receiver (ig/ref ::lzmq/receiver)
|
||||
::http.client/client (ig/ref ::http.client/client)}
|
||||
|
||||
:app.loggers.mattermost/reporter
|
||||
{::lzmq/receiver (ig/ref ::lzmq/receiver)
|
||||
::http.client/client (ig/ref ::http.client/client)}
|
||||
{::http.client/client (ig/ref ::http.client/client)}
|
||||
|
||||
:app.loggers.database/reporter
|
||||
{::lzmq/receiver (ig/ref :app.loggers.zmq/receiver)
|
||||
::db/pool (ig/ref ::db/pool)}
|
||||
{::db/pool (ig/ref ::db/pool)}
|
||||
|
||||
::sto/storage
|
||||
{:pool (ig/ref ::db/pool)
|
||||
:executor (ig/ref ::wrk/executor)
|
||||
|
||||
:backends
|
||||
{::db/pool (ig/ref ::db/pool)
|
||||
::wrk/executor (ig/ref ::wrk/executor)
|
||||
::sto/backends
|
||||
{:assets-s3 (ig/ref [::assets :app.storage.s3/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])}}
|
||||
:assets-fs (ig/ref [::assets :app.storage.fs/backend])}}
|
||||
|
||||
[::assets :app.storage.s3/backend]
|
||||
{:region (cf/get :storage-assets-s3-region)
|
||||
:endpoint (cf/get :storage-assets-s3-endpoint)
|
||||
:bucket (cf/get :storage-assets-s3-bucket)
|
||||
:executor (ig/ref ::wrk/executor)}
|
||||
{::sto.s3/region (cf/get :storage-assets-s3-region)
|
||||
::sto.s3/endpoint (cf/get :storage-assets-s3-endpoint)
|
||||
::sto.s3/bucket (cf/get :storage-assets-s3-bucket)
|
||||
::wrk/executor (ig/ref ::wrk/executor)}
|
||||
|
||||
[::assets :app.storage.fs/backend]
|
||||
{:directory (cf/get :storage-assets-fs-directory)}
|
||||
{::sto.fs/directory (cf/get :storage-assets-fs-directory)}
|
||||
})
|
||||
|
||||
|
||||
(def worker-config
|
||||
{::wrk/cron
|
||||
{::wrk/scheduled-executor (ig/ref ::wrk/scheduled-executor)
|
||||
::wrk/registry (ig/ref ::wrk/registry)
|
||||
{::wrk/registry (ig/ref ::wrk/registry)
|
||||
::db/pool (ig/ref ::db/pool)
|
||||
::wrk/entries
|
||||
[{:cron #app/cron "0 0 * * * ?" ;; hourly
|
||||
|
||||
@@ -9,11 +9,18 @@
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.logging :as l]
|
||||
[app.common.media :as cm]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.schema.generators :as sg]
|
||||
[app.common.schema.openapi :as-alias oapi]
|
||||
[app.common.spec :as us]
|
||||
[app.config :as cf]
|
||||
[app.db :as-alias db]
|
||||
[app.storage :as-alias sto]
|
||||
[app.storage.tmp :as tmp]
|
||||
[app.util.svg :as svg]
|
||||
[app.util.time :as dt]
|
||||
[buddy.core.bytes :as bb]
|
||||
[buddy.core.codecs :as bc]
|
||||
[clojure.java.shell :as sh]
|
||||
@@ -26,6 +33,9 @@
|
||||
org.im4java.core.IMOperation
|
||||
org.im4java.core.Info))
|
||||
|
||||
(def default-max-file-size
|
||||
(* 1024 1024 30)) ; 30 MiB
|
||||
|
||||
(s/def ::path fs/path?)
|
||||
(s/def ::filename string?)
|
||||
(s/def ::size integer?)
|
||||
@@ -41,6 +51,27 @@
|
||||
(s/keys :req-un [::path]
|
||||
:opt-un [::mtype]))
|
||||
|
||||
(sm/def! ::fs/path
|
||||
{:type ::fs/path
|
||||
:pred fs/path?
|
||||
:type-properties
|
||||
{:title "path"
|
||||
:description "filesystem path"
|
||||
:error/message "expected a valid fs path instance"
|
||||
:gen/gen (sg/generator :string)
|
||||
::oapi/type "string"
|
||||
::oapi/format "unix-path"
|
||||
::oapi/decode fs/path}})
|
||||
|
||||
(sm/def! ::upload
|
||||
[:map {:title "Upload"}
|
||||
[:filename :string]
|
||||
[:size :int]
|
||||
[:path ::fs/path]
|
||||
[:mtype {:optional true} :string]
|
||||
[:headers {:optional true}
|
||||
[:map-of :string :string]]])
|
||||
|
||||
(defn validate-media-type!
|
||||
([upload] (validate-media-type! upload cm/valid-image-types))
|
||||
([upload allowed]
|
||||
@@ -51,6 +82,16 @@
|
||||
|
||||
upload))
|
||||
|
||||
(defn validate-media-size!
|
||||
[upload]
|
||||
(when (> (:size upload) (cf/get :media-max-file-size default-max-file-size))
|
||||
(ex/raise :type :restriction
|
||||
:code :media-max-file-size-reached
|
||||
:hint (str/ffmt "the uploaded file size % is greater than the maximum %"
|
||||
(:size upload)
|
||||
default-max-file-size)))
|
||||
upload)
|
||||
|
||||
(defmulti process :cmd)
|
||||
(defmulti process-error class)
|
||||
|
||||
@@ -166,7 +207,7 @@
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-svg-file
|
||||
:hint "uploaded svg does not provides dimensions"))
|
||||
(merge input info))
|
||||
(merge input info {:ts (dt/now)}))
|
||||
|
||||
(let [instance (Info. (str path))
|
||||
mtype' (.getProperty instance "Mime type")]
|
||||
@@ -181,10 +222,12 @@
|
||||
;; any frame.
|
||||
(assoc input
|
||||
:width (.getPageWidth instance)
|
||||
:height (.getPageHeight instance))))))
|
||||
:height (.getPageHeight instance)
|
||||
:ts (dt/now))))))
|
||||
|
||||
(defmethod process-error org.im4java.core.InfoException
|
||||
[error]
|
||||
(l/error :hint "unexpected error on processing image" :cause error)
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-image
|
||||
:hint "invalid image"
|
||||
@@ -297,8 +340,7 @@
|
||||
"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)))))
|
||||
(assoc storage ::sto/backend (cf/get :assets-storage-backend :assets-fs)))
|
||||
([storage pool-or-conn]
|
||||
(-> (configure-assets-storage storage)
|
||||
(assoc ::db/pool-or-conn pool-or-conn))))
|
||||
|
||||
@@ -87,13 +87,26 @@
|
||||
::definitions definitions
|
||||
::registry registry}))
|
||||
|
||||
|
||||
(defn- handler
|
||||
[registry _ respond _]
|
||||
[registry _]
|
||||
(let [samples (.metricFamilySamples ^CollectorRegistry registry)
|
||||
writer (StringWriter.)]
|
||||
(TextFormat/write004 writer samples)
|
||||
(respond {:headers {"content-type" TextFormat/CONTENT_TYPE_004}
|
||||
:body (.toString writer)})))
|
||||
{:headers {"content-type" TextFormat/CONTENT_TYPE_004}
|
||||
:body (.toString writer)}))
|
||||
|
||||
|
||||
|
||||
(s/def ::routes vector?)
|
||||
(defmethod ig/pre-init-spec ::routes [_]
|
||||
(s/keys :req [::metrics]))
|
||||
|
||||
(defmethod ig/init-key ::routes
|
||||
[_ {:keys [::metrics]}]
|
||||
(let [registry (::registry metrics)]
|
||||
["/metrics" {:handler (partial handler registry)
|
||||
:allowed-methods #{:get}}]))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; Implementation
|
||||
|
||||
@@ -6,8 +6,12 @@
|
||||
|
||||
(ns app.migrations
|
||||
(:require
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.logging :as l]
|
||||
[app.db :as db]
|
||||
[app.migrations.clj.migration-0023 :as mg0023]
|
||||
[app.util.migrations :as mg]
|
||||
[clojure.spec.alpha :as s]
|
||||
[integrant.core :as ig]))
|
||||
|
||||
(def migrations
|
||||
@@ -302,7 +306,38 @@
|
||||
{:name "0098-add-quotes-table"
|
||||
:fn (mg/resource "app/migrations/sql/0098-add-quotes-table.sql")}
|
||||
|
||||
])
|
||||
{:name "0099-add-access-token-table"
|
||||
:fn (mg/resource "app/migrations/sql/0099-add-access-token-table.sql")}
|
||||
|
||||
{:name "0100-mod-profile-indexes"
|
||||
:fn (mg/resource "app/migrations/sql/0100-mod-profile-indexes.sql")}
|
||||
|
||||
(defmethod ig/init-key ::migrations [_ _] migrations)
|
||||
{:name "0101-mod-server-error-report-table"
|
||||
:fn (mg/resource "app/migrations/sql/0101-mod-server-error-report-table.sql")}
|
||||
|
||||
{:name "0102-mod-access-token-table"
|
||||
:fn (mg/resource "app/migrations/sql/0102-mod-access-token-table.sql")}
|
||||
|
||||
{:name "0103-mod-file-object-thumbnail-table"
|
||||
:fn (mg/resource "app/migrations/sql/0103-mod-file-object-thumbnail-table.sql")}
|
||||
|
||||
{:name "0104-mod-file-thumbnail-table"
|
||||
:fn (mg/resource "app/migrations/sql/0104-mod-file-thumbnail-table.sql")}
|
||||
|
||||
])
|
||||
|
||||
(defn apply-migrations!
|
||||
[pool name migrations]
|
||||
(dm/with-open [conn (db/open pool)]
|
||||
(mg/setup! conn)
|
||||
(mg/migrate! conn {:name name :steps migrations})))
|
||||
|
||||
(defmethod ig/pre-init-spec ::migrations
|
||||
[_]
|
||||
(s/keys :req [::db/pool]))
|
||||
|
||||
(defmethod ig/init-key ::migrations
|
||||
[module {:keys [::db/pool]}]
|
||||
(when-not (db/read-only? pool)
|
||||
(l/info :hint "running migrations" :module module)
|
||||
(some->> (seq migrations) (apply-migrations! pool "main"))))
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
DROP TABLE IF EXISTS access_token;
|
||||
CREATE TABLE access_token (
|
||||
id uuid NOT NULL DEFAULT uuid_generate_v4() PRIMARY KEY,
|
||||
profile_id uuid NOT NULL REFERENCES profile(id) ON DELETE CASCADE DEFERRABLE,
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
name text NOT NULL,
|
||||
token text NOT NULL,
|
||||
perms text[] NULL
|
||||
);
|
||||
|
||||
ALTER TABLE access_token
|
||||
ALTER COLUMN name SET STORAGE external,
|
||||
ALTER COLUMN token SET STORAGE external,
|
||||
ALTER COLUMN perms SET STORAGE external;
|
||||
|
||||
CREATE INDEX access_token__profile_id__idx ON access_token(profile_id);
|
||||
31
backend/src/app/migrations/sql/0100-mod-profile-indexes.sql
Normal file
31
backend/src/app/migrations/sql/0100-mod-profile-indexes.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
ALTER TABLE profile
|
||||
ADD COLUMN default_project_id uuid NULL REFERENCES project(id) ON DELETE SET NULL DEFERRABLE,
|
||||
ADD COLUMN default_team_id uuid NULL REFERENCES team(id) ON DELETE SET NULL DEFERRABLE;
|
||||
|
||||
CREATE INDEX profile__default_project__idx ON profile(default_project_id);
|
||||
CREATE INDEX profile__default_team__idx ON profile(default_team_id);
|
||||
|
||||
with profiles as (
|
||||
select p.id,
|
||||
tpr.team_id as default_team_id,
|
||||
ppr.project_id as default_project_id
|
||||
from profile as p
|
||||
join team_profile_rel as tpr
|
||||
on (tpr.profile_id = p.id and
|
||||
tpr.is_owner is true)
|
||||
join project_profile_rel as ppr
|
||||
on (ppr.profile_id = p.id and
|
||||
ppr.is_owner is true)
|
||||
join project as pj
|
||||
on (pj.id = ppr.project_id)
|
||||
join team as tm
|
||||
on (tm.id = tpr.team_id)
|
||||
where pj.is_default is true
|
||||
and tm.is_default is true
|
||||
and pj.team_id = tm.id
|
||||
)
|
||||
update profile
|
||||
set default_team_id = p.default_team_id,
|
||||
default_project_id = p.default_project_id
|
||||
from profiles as p
|
||||
where profile.id = p.id;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE server_error_report
|
||||
ADD COLUMN version integer DEFAULT 1;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE access_token
|
||||
ADD COLUMN expires_at timestamptz NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE file_object_thumbnail
|
||||
ADD COLUMN media_id uuid NULL REFERENCES storage_object(id) ON DELETE CASCADE DEFERRABLE;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE file_thumbnail
|
||||
ADD COLUMN media_id uuid NULL REFERENCES storage_object(id) ON DELETE CASCADE DEFERRABLE;
|
||||
@@ -8,20 +8,18 @@
|
||||
"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.redis :as redis]
|
||||
[app.util.async :as aa]
|
||||
[app.redis :as rds]
|
||||
[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]
|
||||
[promesa.exec :as px]))
|
||||
[promesa.exec :as px]
|
||||
[promesa.exec.csp :as sp]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
@@ -34,132 +32,116 @@
|
||||
(def ^:private xform-prefix-topic
|
||||
(map (fn [obj] (update obj :topic prefix-topic))))
|
||||
|
||||
(declare ^:private redis-connect)
|
||||
(declare ^:private redis-disconnect)
|
||||
(declare ^:private redis-pub)
|
||||
(declare ^:private redis-sub)
|
||||
(declare ^:private redis-unsub)
|
||||
(declare ^:private redis-pub!)
|
||||
(declare ^:private redis-sub!)
|
||||
(declare ^:private redis-unsub!)
|
||||
(declare ^:private start-io-loop!)
|
||||
(declare ^:private subscribe-to-topics)
|
||||
(declare ^:private unsubscribe-channels)
|
||||
|
||||
(defmethod ig/prep-key ::msgbus
|
||||
[_ cfg]
|
||||
(merge {:buffer-size 128
|
||||
:timeout (dt/duration {:seconds 30})}
|
||||
(d/without-nils cfg)))
|
||||
|
||||
(s/def ::cmd-ch ::aa/channel)
|
||||
(s/def ::rcv-ch ::aa/channel)
|
||||
(s/def ::pub-ch ::aa/channel)
|
||||
(s/def ::cmd-ch sp/chan?)
|
||||
(s/def ::rcv-ch sp/chan?)
|
||||
(s/def ::pub-ch sp/chan?)
|
||||
(s/def ::state ::us/agent)
|
||||
(s/def ::pconn ::redis/connection-holder)
|
||||
(s/def ::sconn ::redis/connection-holder)
|
||||
(s/def ::pconn ::rds/connection-holder)
|
||||
(s/def ::sconn ::rds/connection-holder)
|
||||
(s/def ::msgbus
|
||||
(s/keys :req [::cmd-ch ::rcv-ch ::pub-ch ::state ::pconn ::sconn ::wrk/executor]))
|
||||
|
||||
(s/def ::buffer-size ::us/integer)
|
||||
|
||||
(defmethod ig/pre-init-spec ::msgbus [_]
|
||||
(s/keys :req-un [::buffer-size ::redis/timeout ::redis/redis ::wrk/executor]))
|
||||
(s/keys :req [::rds/redis ::wrk/executor]))
|
||||
|
||||
(defmethod ig/prep-key ::msgbus
|
||||
[_ cfg]
|
||||
(-> cfg
|
||||
(assoc ::buffer-size 128)
|
||||
(assoc ::timeout (dt/duration {:seconds 30}))))
|
||||
|
||||
(defmethod ig/init-key ::msgbus
|
||||
[_ {:keys [buffer-size executor] :as cfg}]
|
||||
[_ {:keys [::buffer-size ::wrk/executor ::timeout ::rds/redis] :as cfg}]
|
||||
(l/info :hint "initialize msgbus" :buffer-size buffer-size)
|
||||
(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)
|
||||
(let [cmd-ch (sp/chan :buf buffer-size)
|
||||
rcv-ch (sp/chan :buf (sp/dropping-buffer buffer-size))
|
||||
pub-ch (sp/chan :buf (sp/dropping-buffer buffer-size)
|
||||
:xf xform-prefix-topic)
|
||||
state (agent {})
|
||||
msgbus (-> (redis-connect cfg)
|
||||
|
||||
pconn (rds/connect redis :timeout timeout)
|
||||
sconn (rds/connect redis :type :pubsub :timeout timeout)
|
||||
msgbus (-> cfg
|
||||
(assoc ::pconn pconn)
|
||||
(assoc ::sconn sconn)
|
||||
(assoc ::cmd-ch cmd-ch)
|
||||
(assoc ::rcv-ch rcv-ch)
|
||||
(assoc ::pub-ch pub-ch)
|
||||
(assoc ::state state)
|
||||
(assoc ::wrk/executor executor))]
|
||||
|
||||
(us/verify! ::msgbus msgbus)
|
||||
|
||||
(set-error-handler! state #(l/error :cause % :hint "unexpected error on agent" ::l/async false))
|
||||
(set-error-handler! state #(l/error :cause % :hint "unexpected error on agent" ::l/sync? true))
|
||||
(set-error-mode! state :continue)
|
||||
(start-io-loop! msgbus)
|
||||
|
||||
msgbus))
|
||||
|
||||
(defn sub!
|
||||
[{:keys [::state ::wrk/executor] :as cfg} & {:keys [topic topics chan]}]
|
||||
(let [done-ch (a/chan)
|
||||
topics (into [] (map prefix-topic) (if topic [topic] topics))]
|
||||
(l/debug :hint "subscribe" :topics topics)
|
||||
(send-via executor state subscribe-to-topics cfg topics chan done-ch)
|
||||
done-ch))
|
||||
|
||||
(defn pub!
|
||||
[{::keys [pub-ch]} & {:as params}]
|
||||
(a/go
|
||||
(a/>! pub-ch params)))
|
||||
|
||||
(defn purge!
|
||||
[{:keys [::state ::wrk/executor] :as msgbus} chans]
|
||||
(l/trace :hint "purge" :chans (count chans))
|
||||
(let [done-ch (a/chan)]
|
||||
(send-via executor state unsubscribe-channels msgbus chans done-ch)
|
||||
done-ch))
|
||||
(assoc msgbus ::io-thr (start-io-loop! msgbus))))
|
||||
|
||||
(defmethod ig/halt-key! ::msgbus
|
||||
[_ msgbus]
|
||||
(redis-disconnect msgbus)
|
||||
(a/close! (::cmd-ch msgbus))
|
||||
(a/close! (::rcv-ch msgbus))
|
||||
(a/close! (::pub-ch msgbus)))
|
||||
(px/interrupt! (::io-thr msgbus))
|
||||
(sp/close! (::cmd-ch msgbus))
|
||||
(sp/close! (::rcv-ch msgbus))
|
||||
(sp/close! (::pub-ch msgbus))
|
||||
(d/close! (::pconn msgbus))
|
||||
(d/close! (::sconn msgbus)))
|
||||
|
||||
(defn sub!
|
||||
[{:keys [::state ::wrk/executor] :as cfg} & {:keys [topic topics chan]}]
|
||||
(let [topics (into [] (map prefix-topic) (if topic [topic] topics))]
|
||||
(l/debug :hint "subscribe" :topics topics :chan (hash chan))
|
||||
(send-via executor state subscribe-to-topics cfg topics chan)
|
||||
nil))
|
||||
|
||||
(defn pub!
|
||||
[{::keys [pub-ch]} & {:as params}]
|
||||
(sp/put! pub-ch params))
|
||||
|
||||
(defn purge!
|
||||
[{:keys [::state ::wrk/executor] :as msgbus} chans]
|
||||
(l/debug :hint "purge" :chans (count chans))
|
||||
(send-via executor state unsubscribe-channels msgbus chans)
|
||||
nil)
|
||||
|
||||
;; --- IMPL
|
||||
|
||||
(defn- redis-connect
|
||||
[{:keys [timeout redis] :as cfg}]
|
||||
(let [pconn (redis/connect redis :timeout timeout)
|
||||
sconn (redis/connect redis :type :pubsub :timeout timeout)]
|
||||
{::pconn pconn
|
||||
::sconn sconn}))
|
||||
|
||||
(defn- redis-disconnect
|
||||
[{:keys [::pconn ::sconn] :as cfg}]
|
||||
(d/close! pconn)
|
||||
(d/close! sconn))
|
||||
|
||||
(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."
|
||||
already established."
|
||||
[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))
|
||||
(l/trace :hint "open subscription" :topic topic ::l/sync? true)
|
||||
(redis-sub! cfg topic))
|
||||
nsubs))
|
||||
|
||||
(defn- disj-subscription
|
||||
"A low level function responsible on removing subscriptions. The
|
||||
subscription is truly removed from redis once no single local
|
||||
subscription is look for it. Intended to be executed in agent."
|
||||
subscription is look for it."
|
||||
[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))
|
||||
(l/trace :hint "close subscription" :topic topic ::l/sync? true)
|
||||
(redis-unsub! cfg topic))
|
||||
nsubs))
|
||||
|
||||
(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]
|
||||
(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))))
|
||||
"Function responsible to attach local subscription to the state."
|
||||
[state cfg topics chan]
|
||||
(let [state (update state :chans assoc chan topics)]
|
||||
(reduce (fn [state topic]
|
||||
(update-in state [:topics topic] conj-subscription cfg topic chan))
|
||||
state
|
||||
topics)))
|
||||
|
||||
(defn- unsubscribe-single-channel
|
||||
(defn- unsubscribe-channel
|
||||
"Auxiliary function responsible on removing a single local
|
||||
subscription from the state."
|
||||
[state cfg chan]
|
||||
@@ -174,87 +156,113 @@
|
||||
"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]
|
||||
(aa/with-closing done-ch
|
||||
(reduce #(unsubscribe-single-channel %1 cfg %2) state channels)))
|
||||
[state cfg channels]
|
||||
(reduce #(unsubscribe-channel %1 cfg %2) state channels))
|
||||
|
||||
(defn- create-listener
|
||||
[rcv-ch]
|
||||
(redis/pubsub-listener
|
||||
(rds/pubsub-listener
|
||||
:on-message (fn [_ 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)
|
||||
(when-not (sp/offer! rcv-ch val)
|
||||
(l/warn :msg "dropping message on subscription loop"))))))
|
||||
|
||||
(defn- process-input!
|
||||
[{:keys [::state ::wrk/executor] :as cfg} topic message]
|
||||
(let [chans (get-in @state [:topics topic])]
|
||||
(when-let [closed (loop [chans (seq chans)
|
||||
closed #{}]
|
||||
(if-let [ch (first chans)]
|
||||
(if (sp/put! ch message)
|
||||
(recur (rest chans) closed)
|
||||
(recur (rest chans) (conj closed ch)))
|
||||
(seq closed)))]
|
||||
(send-via executor state unsubscribe-channels cfg closed))))
|
||||
|
||||
|
||||
(defn start-io-loop!
|
||||
[{:keys [::sconn ::rcv-ch ::pub-ch ::state ::wrk/executor] :as cfg}]
|
||||
(redis/add-listener! sconn (create-listener rcv-ch))
|
||||
(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))))
|
||||
(rds/add-listener! sconn (create-listener rcv-ch))
|
||||
|
||||
(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))))
|
||||
]
|
||||
(px/thread
|
||||
{:name "penpot/msgbus-io-loop"}
|
||||
(px/thread
|
||||
{:name "penpot/msgbus/io-loop"
|
||||
:virtual true}
|
||||
(try
|
||||
(loop []
|
||||
(let [[val port] (a/alts!! [pub-ch rcv-ch])]
|
||||
(let [timeout-ch (sp/timeout-chan 1000)
|
||||
[val port] (sp/alts! [timeout-ch pub-ch rcv-ch])]
|
||||
(cond
|
||||
(nil? val)
|
||||
(do
|
||||
(l/trace :hint "stopping io-loop, nil received")
|
||||
(send-via executor state (fn [state]
|
||||
(->> (vals state)
|
||||
(mapcat identity)
|
||||
(filter some?)
|
||||
(run! a/close!))
|
||||
nil)))
|
||||
|
||||
(= port rcv-ch)
|
||||
(do
|
||||
(a/<!! (process-incoming val))
|
||||
(identical? port timeout-ch)
|
||||
(let [closed (->> (:chans @state)
|
||||
(map key)
|
||||
(filter sp/closed?))]
|
||||
(when (seq closed)
|
||||
(send-via executor state unsubscribe-channels cfg closed)
|
||||
(l/debug :hint "proactively purge channels" :count (count closed)))
|
||||
(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))))))))
|
||||
(nil? val)
|
||||
(throw (InterruptedException. "internally interrupted"))
|
||||
|
||||
(defn- redis-pub
|
||||
(identical? port rcv-ch)
|
||||
(let [{:keys [topic message]} val]
|
||||
(process-input! cfg topic message)
|
||||
(recur))
|
||||
|
||||
(identical? port pub-ch)
|
||||
(do
|
||||
(redis-pub! cfg val)
|
||||
(recur)))))
|
||||
|
||||
(catch InterruptedException _
|
||||
(l/trace :hint "io-loop thread interrumpted"))
|
||||
|
||||
(catch Throwable cause
|
||||
(l/error :hint "unexpected exception on io-loop thread"
|
||||
:cause cause))
|
||||
(finally
|
||||
(l/trace :hint "clearing io-loop state")
|
||||
(when-let [chans (:chans @state)]
|
||||
(run! sp/close! (keys chans)))
|
||||
|
||||
(l/debug :hint "io-loop thread terminated")))))
|
||||
|
||||
|
||||
(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)]
|
||||
(-> (redis/publish! pconn topic message)
|
||||
(p/finally (fn [_ cause]
|
||||
(when (and cause (redis/open? pconn))
|
||||
(a/offer! res cause))
|
||||
(a/close! res))))
|
||||
res))
|
||||
(try
|
||||
(p/await! (rds/publish! pconn topic (t/encode message)))
|
||||
(catch InterruptedException cause
|
||||
(throw cause))
|
||||
(catch Throwable cause
|
||||
(l/error :hint "unexpected error on publishing"
|
||||
:message message
|
||||
:cause cause))))
|
||||
|
||||
(defn redis-sub
|
||||
(defn- redis-sub!
|
||||
"Create redis subscription. Blocking operation, intended to be used
|
||||
inside an agent."
|
||||
[{:keys [::sconn] :as cfg} topic]
|
||||
(redis/subscribe! sconn topic))
|
||||
(try
|
||||
(rds/subscribe! sconn topic)
|
||||
(catch InterruptedException cause
|
||||
(throw cause))
|
||||
(catch Throwable cause
|
||||
(l/trace :hint "exception on subscribing" :topic topic :cause cause))))
|
||||
|
||||
(defn redis-unsub
|
||||
(defn- redis-unsub!
|
||||
"Removes redis subscription. Blocking operation, intended to be used
|
||||
inside an agent."
|
||||
[{:keys [::sconn] :as cfg} topic]
|
||||
(redis/unsubscribe! sconn topic))
|
||||
(try
|
||||
(rds/unsubscribe! sconn topic)
|
||||
(catch InterruptedException cause
|
||||
(throw cause))
|
||||
(catch Throwable cause
|
||||
(l/trace :hint "exception on unsubscribing" :topic topic :cause cause))))
|
||||
|
||||
|
||||
@@ -8,17 +8,21 @@
|
||||
"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.metrics :as mtx]
|
||||
[app.redis.script :as-alias rscript]
|
||||
[app.util.cache :as cache]
|
||||
[app.util.time :as dt]
|
||||
[app.worker :as-alias wrk]
|
||||
[clojure.core :as c]
|
||||
[clojure.java.io :as io]
|
||||
[clojure.spec.alpha :as s]
|
||||
[cuerdas.core :as str]
|
||||
[integrant.core :as ig]
|
||||
[promesa.core :as p])
|
||||
[promesa.core :as p]
|
||||
[promesa.exec :as px])
|
||||
(:import
|
||||
clojure.lang.IDeref
|
||||
clojure.lang.MapEntry
|
||||
@@ -87,7 +91,7 @@
|
||||
(s/def ::connect? ::us/boolean)
|
||||
(s/def ::io-threads ::us/integer)
|
||||
(s/def ::worker-threads ::us/integer)
|
||||
(s/def ::cache #(instance? clojure.lang.Atom %))
|
||||
(s/def ::cache some?)
|
||||
|
||||
(s/def ::redis
|
||||
(s/keys :req [::resources
|
||||
@@ -99,11 +103,11 @@
|
||||
|
||||
(defmethod ig/prep-key ::redis
|
||||
[_ cfg]
|
||||
(let [runtime (Runtime/getRuntime)
|
||||
cpus (.availableProcessors ^Runtime runtime)]
|
||||
(let [cpus (px/get-available-processors)
|
||||
threads (max 1 (int (* cpus 0.2)))]
|
||||
(merge {::timeout (dt/duration "10s")
|
||||
::io-threads (max 3 cpus)
|
||||
::worker-threads (max 3 cpus)}
|
||||
::io-threads (max 3 threads)
|
||||
::worker-threads (max 3 threads)}
|
||||
(d/without-nils cfg))))
|
||||
|
||||
(defmethod ig/pre-init-spec ::redis [_]
|
||||
@@ -129,6 +133,15 @@
|
||||
(def string-codec
|
||||
(RedisCodec/of StringCodec/UTF8 StringCodec/UTF8))
|
||||
|
||||
(defn- create-cache
|
||||
[{:keys [::wrk/executor] :as cfg}]
|
||||
(letfn [(on-remove [key val cause]
|
||||
(l/trace :hint "evict connection (cache)" :key key :reason cause)
|
||||
(some-> val d/close!))]
|
||||
(cache/create :executor executor
|
||||
:on-remove on-remove
|
||||
:keepalive "5m")))
|
||||
|
||||
(defn- initialize-resources
|
||||
"Initialize redis connection resources"
|
||||
[{:keys [::uri ::io-threads ::worker-threads ::connect?] :as cfg}]
|
||||
@@ -145,19 +158,21 @@
|
||||
(timer ^Timer timer)
|
||||
(build))
|
||||
|
||||
redis-uri (RedisURI/create ^String uri)]
|
||||
redis-uri (RedisURI/create ^String uri)
|
||||
cfg (-> cfg
|
||||
(assoc ::resources resources)
|
||||
(assoc ::timer timer)
|
||||
(assoc ::redis-uri redis-uri))]
|
||||
|
||||
(-> cfg
|
||||
(assoc ::resources resources)
|
||||
(assoc ::timer timer)
|
||||
(assoc ::cache (atom {}))
|
||||
(assoc ::redis-uri redis-uri))))
|
||||
(assoc cfg ::cache (create-cache cfg))))
|
||||
|
||||
(defn- shutdown-resources
|
||||
[{:keys [::resources ::cache ::timer]}]
|
||||
(run! d/close! (vals @cache))
|
||||
(cache/invalidate-all! cache)
|
||||
|
||||
(when resources
|
||||
(.shutdown ^ClientResources resources))
|
||||
|
||||
(when timer
|
||||
(.stop ^Timer timer)))
|
||||
|
||||
@@ -173,6 +188,7 @@
|
||||
:default (.connect ^RedisClient client ^RedisCodec codec)
|
||||
:pubsub (.connectPubSub ^RedisClient client ^RedisCodec codec))]
|
||||
|
||||
(l/trc :hint "connect" :hid (hash client))
|
||||
(.setTimeout ^StatefulConnection conn ^Duration timeout)
|
||||
(reify
|
||||
IDeref
|
||||
@@ -180,8 +196,9 @@
|
||||
|
||||
AutoCloseable
|
||||
(close [_]
|
||||
(.close ^StatefulConnection conn)
|
||||
(.shutdown ^RedisClient client)))))
|
||||
(ex/ignoring (.close ^StatefulConnection conn))
|
||||
(ex/ignoring (.shutdown ^RedisClient client))
|
||||
(l/trc :hint "disconnect" :hid (hash client))))))
|
||||
|
||||
(defn connect
|
||||
[state & {:as opts}]
|
||||
@@ -193,19 +210,14 @@
|
||||
|
||||
(defn get-or-connect
|
||||
[{:keys [::cache] :as state} key options]
|
||||
(-> state
|
||||
(assoc ::connection
|
||||
(or (get @cache key)
|
||||
(-> (swap! cache (fn [cache]
|
||||
(when-let [prev (get cache key)]
|
||||
(d/close! prev))
|
||||
(assoc cache key (connect* state options))))
|
||||
(get key))))
|
||||
(dissoc ::cache)))
|
||||
(us/assert! ::redis state)
|
||||
(let [connection (cache/get cache key (fn [_] (connect* state options)))]
|
||||
(-> state
|
||||
(dissoc ::cache)
|
||||
(assoc ::connection connection))))
|
||||
|
||||
(defn add-listener!
|
||||
[{:keys [::connection] :as conn} listener]
|
||||
(us/assert! ::connection-holder conn)
|
||||
(us/assert! ::pubsub-connection connection)
|
||||
(us/assert! ::pubsub-listener listener)
|
||||
(.addListener ^StatefulRedisPubSubConnection @connection
|
||||
@@ -213,10 +225,9 @@
|
||||
conn)
|
||||
|
||||
(defn publish!
|
||||
[{:keys [::connection] :as conn} topic message]
|
||||
[{:keys [::connection]} topic message]
|
||||
(us/assert! ::us/string topic)
|
||||
(us/assert! ::us/bytes message)
|
||||
(us/assert! ::connection-holder conn)
|
||||
(us/assert! ::default-connection connection)
|
||||
|
||||
(let [pcomm (.async ^StatefulRedisConnection @connection)]
|
||||
@@ -224,8 +235,7 @@
|
||||
|
||||
(defn subscribe!
|
||||
"Blocking operation, intended to be used on a thread/agent thread."
|
||||
[{:keys [::connection] :as conn} & topics]
|
||||
(us/assert! ::connection-holder conn)
|
||||
[{:keys [::connection]} & topics]
|
||||
(us/assert! ::pubsub-connection connection)
|
||||
(try
|
||||
(let [topics (into-array String (map str topics))
|
||||
@@ -236,8 +246,7 @@
|
||||
|
||||
(defn unsubscribe!
|
||||
"Blocking operation, intended to be used on a thread/agent thread."
|
||||
[{:keys [::connection] :as conn} & topics]
|
||||
(us/assert! ::connection-holder conn)
|
||||
[{:keys [::connection]} & topics]
|
||||
(us/assert! ::pubsub-connection connection)
|
||||
(try
|
||||
(let [topics (into-array String (map str topics))
|
||||
@@ -247,8 +256,8 @@
|
||||
(throw (InterruptedException. (ex-message cause))))))
|
||||
|
||||
(defn rpush!
|
||||
[{:keys [::connection] :as conn} key payload]
|
||||
(us/assert! ::connection-holder conn)
|
||||
[{:keys [::connection]} key payload]
|
||||
(us/assert! ::default-connection connection)
|
||||
(us/assert! (or (and (vector? payload)
|
||||
(every? bytes? payload))
|
||||
(bytes? payload)))
|
||||
@@ -270,8 +279,8 @@
|
||||
(throw (InterruptedException. (ex-message cause))))))
|
||||
|
||||
(defn blpop!
|
||||
[{:keys [::connection] :as conn} timeout & keys]
|
||||
(us/assert! ::connection-holder conn)
|
||||
[{:keys [::connection]} timeout & keys]
|
||||
(us/assert! ::default-connection connection)
|
||||
(try
|
||||
(let [keys (into-array Object (map str keys))
|
||||
cmd (.sync ^StatefulRedisConnection @connection)
|
||||
@@ -286,8 +295,7 @@
|
||||
(throw (InterruptedException. (ex-message cause))))))
|
||||
|
||||
(defn open?
|
||||
[{:keys [::connection] :as conn}]
|
||||
(us/assert! ::connection-holder conn)
|
||||
[{:keys [::connection]}]
|
||||
(us/assert! ::pubsub-connection connection)
|
||||
(.isOpen ^StatefulConnection @connection))
|
||||
|
||||
@@ -335,7 +343,7 @@
|
||||
(defn eval!
|
||||
[{:keys [::mtx/metrics ::connection] :as state} script]
|
||||
(us/assert! ::redis state)
|
||||
(us/assert! ::connection-holder state)
|
||||
(us/assert! ::default-connection connection)
|
||||
(us/assert! ::rscript/script script)
|
||||
|
||||
(let [cmd (.async ^StatefulRedisConnection @connection)
|
||||
@@ -348,7 +356,7 @@
|
||||
(do
|
||||
(l/error :hint "no script found" :name sname :cause cause)
|
||||
(->> (load-script)
|
||||
(p/mapcat eval-script)))
|
||||
(p/mcat eval-script)))
|
||||
(if-let [on-error (::rscript/on-error script)]
|
||||
(on-error cause)
|
||||
(p/rejected cause))))
|
||||
@@ -379,15 +387,16 @@
|
||||
(load-script []
|
||||
(l/trace :hint "load script" :name sname)
|
||||
(->> (.scriptLoad ^RedisScriptingAsyncCommands cmd
|
||||
^String (read-script))
|
||||
(p/map (fn [sha]
|
||||
(swap! scripts-cache assoc sname sha)
|
||||
sha))))]
|
||||
^String (read-script))
|
||||
(p/fmap (fn [sha]
|
||||
(swap! scripts-cache assoc sname sha)
|
||||
sha))))]
|
||||
|
||||
(if-let [sha (get @scripts-cache sname)]
|
||||
(eval-script sha)
|
||||
(->> (load-script)
|
||||
(p/mapcat eval-script))))))
|
||||
(p/await!
|
||||
(if-let [sha (get @scripts-cache sname)]
|
||||
(eval-script sha)
|
||||
(->> (load-script)
|
||||
(p/mapcat eval-script)))))))
|
||||
|
||||
(defn timeout-exception?
|
||||
[cause]
|
||||
|
||||
@@ -7,17 +7,19 @@
|
||||
(ns app.rpc
|
||||
(:require
|
||||
[app.auth.ldap :as-alias ldap]
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.logging :as l]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.spec :as us]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.http :as-alias http]
|
||||
[app.http.access-token :as actoken]
|
||||
[app.http.client :as-alias http.client]
|
||||
[app.http.session :as-alias http.session]
|
||||
[app.http.session :as session]
|
||||
[app.loggers.audit :as audit]
|
||||
[app.loggers.webhooks :as-alias webhooks]
|
||||
[app.main :as-alias main]
|
||||
[app.metrics :as mtx]
|
||||
[app.msgbus :as-alias mbus]
|
||||
[app.rpc.climit :as climit]
|
||||
@@ -32,7 +34,6 @@
|
||||
[clojure.spec.alpha :as s]
|
||||
[integrant.core :as ig]
|
||||
[promesa.core :as p]
|
||||
[promesa.exec :as px]
|
||||
[yetti.request :as yrq]
|
||||
[yetti.response :as yrs]))
|
||||
|
||||
@@ -44,12 +45,10 @@
|
||||
|
||||
(defn- handle-response-transformation
|
||||
[response request mdata]
|
||||
(let [transform-fn (reduce (fn [res-fn transform-fn]
|
||||
(fn [request response]
|
||||
(p/then (res-fn request response) #(transform-fn request %))))
|
||||
(constantly response)
|
||||
(::response-transform-fns mdata))]
|
||||
(transform-fn request response)))
|
||||
(reduce (fn [response transform-fn]
|
||||
(transform-fn request response))
|
||||
response
|
||||
(::response-transform-fns mdata)))
|
||||
|
||||
(defn- handle-before-comple-hook
|
||||
[response mdata]
|
||||
@@ -60,306 +59,209 @@
|
||||
(defn- handle-response
|
||||
[request result]
|
||||
(if (fn? result)
|
||||
(p/wrap (result request))
|
||||
(result request)
|
||||
(let [mdata (meta result)]
|
||||
(p/-> (yrs/response {:status (::http/status mdata 200)
|
||||
:headers (::http/headers mdata {})
|
||||
:body (rph/unwrap result)})
|
||||
(handle-response-transformation request mdata)
|
||||
(handle-before-comple-hook mdata)))))
|
||||
(-> {::yrs/status (::http/status mdata 200)
|
||||
::yrs/headers (::http/headers mdata {})
|
||||
::yrs/body (rph/unwrap result)}
|
||||
(handle-response-transformation request mdata)
|
||||
(handle-before-comple-hook mdata)))))
|
||||
|
||||
(defn- rpc-query-handler
|
||||
"Ring handler that dispatches query requests and convert between
|
||||
internal async flow into ring async flow."
|
||||
[methods {:keys [profile-id session-id path-params params] :as request} respond raise]
|
||||
(let [type (keyword (:type path-params))
|
||||
data (-> params
|
||||
(assoc ::request-at (dt/now))
|
||||
(assoc ::http/request request))
|
||||
data (if profile-id
|
||||
(-> data
|
||||
(assoc :profile-id profile-id)
|
||||
(assoc ::profile-id profile-id)
|
||||
(assoc ::session-id session-id))
|
||||
(dissoc data :profile-id ::profile-id))
|
||||
method (get methods type default-handler)]
|
||||
|
||||
(-> (method data)
|
||||
(p/then (partial handle-response request))
|
||||
(p/then respond)
|
||||
(p/catch (fn [cause]
|
||||
(let [context {:profile-id profile-id}]
|
||||
(raise (ex/wrap-with-context cause context))))))))
|
||||
|
||||
(defn- rpc-mutation-handler
|
||||
"Ring handler that dispatches mutation requests and convert between
|
||||
internal async flow into ring async flow."
|
||||
[methods {:keys [profile-id session-id path-params params] :as request} respond raise]
|
||||
(let [type (keyword (:type path-params))
|
||||
data (-> params
|
||||
(assoc ::request-at (dt/now))
|
||||
(assoc ::http/request request))
|
||||
data (if profile-id
|
||||
(-> data
|
||||
(assoc :profile-id profile-id)
|
||||
(assoc ::profile-id profile-id)
|
||||
(assoc ::session-id session-id))
|
||||
(dissoc data :profile-id ::profile-id))
|
||||
method (get methods type default-handler)]
|
||||
(-> (method data)
|
||||
(p/then (partial handle-response request))
|
||||
(p/then respond)
|
||||
(p/catch (fn [cause]
|
||||
(let [context {:profile-id profile-id}]
|
||||
(raise (ex/wrap-with-context cause context))))))))
|
||||
|
||||
(defn- rpc-command-handler
|
||||
(defn- rpc-handler
|
||||
"Ring handler that dispatches cmd requests and convert between
|
||||
internal async flow into ring async flow."
|
||||
[methods {:keys [profile-id session-id path-params params] :as request} respond raise]
|
||||
(let [cmd (keyword (:type path-params))
|
||||
etag (yrq/get-header request "if-none-match")
|
||||
[methods {:keys [params path-params] :as request}]
|
||||
(let [type (keyword (:type path-params))
|
||||
etag (yrq/get-header request "if-none-match")
|
||||
profile-id (or (::session/profile-id request)
|
||||
(::actoken/profile-id request))
|
||||
|
||||
data (-> params
|
||||
(assoc ::request-at (dt/now))
|
||||
(assoc ::http/request request)
|
||||
(assoc ::cond/key etag)
|
||||
(cond-> (uuid? profile-id)
|
||||
(-> (assoc ::profile-id profile-id)
|
||||
(assoc ::session-id session-id))))
|
||||
data (-> params
|
||||
(assoc ::request-at (dt/now))
|
||||
(assoc ::session/id (::session/id request))
|
||||
(assoc ::cond/key etag)
|
||||
(cond-> (uuid? profile-id)
|
||||
(assoc ::profile-id profile-id)))
|
||||
|
||||
data (vary-meta data assoc ::http/request request)
|
||||
method (get methods type default-handler)]
|
||||
|
||||
method (get methods cmd default-handler)]
|
||||
(binding [cond/*enabled* true]
|
||||
(-> (method data)
|
||||
(p/then (partial handle-response request))
|
||||
(p/then respond)
|
||||
(p/catch (fn [cause]
|
||||
(let [context {:profile-id profile-id}]
|
||||
(raise (ex/wrap-with-context cause context)))))))))
|
||||
(let [response (method data)]
|
||||
(handle-response request response)))))
|
||||
|
||||
(defn- wrap-metrics
|
||||
"Wrap service method with metrics measurement."
|
||||
[{:keys [metrics ::metrics-id]} f mdata]
|
||||
[{:keys [::mtx/metrics ::metrics-id]} f mdata]
|
||||
(let [labels (into-array String [(::sv/name mdata)])]
|
||||
(fn [cfg params]
|
||||
(let [tp (dt/tpoint)]
|
||||
(p/finally
|
||||
(try
|
||||
(f cfg params)
|
||||
(fn [_ _]
|
||||
(finally
|
||||
(mtx/run! metrics
|
||||
:id metrics-id
|
||||
:val (inst-ms (tp))
|
||||
:labels labels)))))))
|
||||
|
||||
(defn- wrap-dispatch
|
||||
"Wraps service method into async flow, with the ability to dispatching
|
||||
it to a preconfigured executor service."
|
||||
[{:keys [executor] :as cfg} f mdata]
|
||||
(with-meta
|
||||
(fn [cfg params]
|
||||
(->> (px/submit! executor (px/wrap-bindings #(f cfg params)))
|
||||
(p/mapcat p/wrap)
|
||||
(p/map rph/wrap)))
|
||||
mdata))
|
||||
(defn- wrap-authentication
|
||||
[_ f mdata]
|
||||
(fn [cfg params]
|
||||
(let [profile-id (::profile-id params)]
|
||||
(if (and (::auth mdata true) (not (uuid? profile-id)))
|
||||
(ex/raise :type :authentication
|
||||
:code :authentication-required
|
||||
:hint "authentication required for this endpoint")
|
||||
(f cfg params)))))
|
||||
|
||||
(defn- wrap-audit
|
||||
[cfg f mdata]
|
||||
[_ f mdata]
|
||||
(if (or (contains? cf/flags :webhooks)
|
||||
(contains? cf/flags :audit-log))
|
||||
(letfn [(handle-audit [params result]
|
||||
(let [resultm (meta result)
|
||||
request (::http/request params)
|
||||
|
||||
profile-id (or (::audit/profile-id resultm)
|
||||
(:profile-id result)
|
||||
(if (= (::type cfg) "command")
|
||||
(::profile-id params)
|
||||
(:profile-id params))
|
||||
uuid/zero)
|
||||
|
||||
props (-> (or (::audit/replace-props resultm)
|
||||
(-> params
|
||||
(merge (::audit/props resultm))
|
||||
(dissoc :profile-id)
|
||||
(dissoc :type)))
|
||||
(audit/clean-props))
|
||||
|
||||
event {:type (or (::audit/type resultm)
|
||||
(::type cfg))
|
||||
:name (or (::audit/name resultm)
|
||||
(::sv/name mdata))
|
||||
:profile-id profile-id
|
||||
:ip-addr (some-> request audit/parse-client-ip)
|
||||
:props props
|
||||
|
||||
;; NOTE: for batch-key lookup we need the params as-is
|
||||
;; because the rpc api does not need to know the
|
||||
;; audit/webhook specific object layout.
|
||||
::params (dissoc params ::http/request)
|
||||
|
||||
::webhooks/batch-key
|
||||
(or (::webhooks/batch-key mdata)
|
||||
(::webhooks/batch-key resultm))
|
||||
|
||||
::webhooks/batch-timeout
|
||||
(or (::webhooks/batch-timeout mdata)
|
||||
(::webhooks/batch-timeout resultm))
|
||||
|
||||
::webhooks/event?
|
||||
(or (::webhooks/event? mdata)
|
||||
(::webhooks/event? resultm)
|
||||
false)}]
|
||||
|
||||
(audit/submit! cfg event)))
|
||||
|
||||
(handle-request [cfg params]
|
||||
(->> (f cfg params)
|
||||
(p/fnly (fn [result cause]
|
||||
(when-not cause
|
||||
(handle-audit params result))))))]
|
||||
|
||||
(if-not (::audit/skip mdata)
|
||||
(with-meta handle-request mdata)
|
||||
f))
|
||||
(if-not (::audit/skip mdata)
|
||||
(fn [cfg params]
|
||||
(let [result (f cfg params)]
|
||||
(->> (audit/prepare-event cfg mdata params result)
|
||||
(audit/submit! cfg))
|
||||
result))
|
||||
f)
|
||||
f))
|
||||
|
||||
(defn- wrap-spec-conform
|
||||
[_ f mdata]
|
||||
;; NOTE: skip spec conform operation on rpc methods that already
|
||||
;; uses malli validation mechanism.
|
||||
(if (contains? mdata ::sm/params)
|
||||
f
|
||||
(if-let [spec (ex/ignoring (s/spec (::sv/spec mdata)))]
|
||||
(fn [cfg params]
|
||||
(f cfg (us/conform spec params)))
|
||||
f)))
|
||||
|
||||
(defn- wrap-params-validation
|
||||
[_ f mdata]
|
||||
(if-let [schema (::sm/params mdata)]
|
||||
(let [schema (sm/schema schema)
|
||||
valid? (sm/validator schema)
|
||||
explain (sm/explainer schema)
|
||||
decode (sm/decoder schema sm/default-transformer)]
|
||||
|
||||
(fn [cfg params]
|
||||
(let [params (decode params)]
|
||||
(if (valid? params)
|
||||
(f cfg params)
|
||||
(ex/raise :type :validation
|
||||
:code :params-validation
|
||||
::sm/explain (explain params))))))
|
||||
f))
|
||||
|
||||
(defn- wrap-output-validation
|
||||
[_ f mdata]
|
||||
(if (contains? cf/flags :rpc-output-validation)
|
||||
(or (when-let [schema (::sm/result mdata)]
|
||||
(let [schema (sm/schema schema)
|
||||
valid? (sm/validator schema)
|
||||
explain (sm/explainer schema)]
|
||||
(fn [cfg params]
|
||||
(let [response (f cfg params)]
|
||||
(when (map? response)
|
||||
(when-not (valid? response)
|
||||
(ex/raise :type :validation
|
||||
:code :data-validation
|
||||
::sm/explain (explain response))))
|
||||
response))))
|
||||
f)
|
||||
f))
|
||||
|
||||
(defn- wrap-all
|
||||
[cfg f mdata]
|
||||
(as-> f $
|
||||
(wrap-metrics cfg $ mdata)
|
||||
(cond/wrap cfg $ mdata)
|
||||
(retry/wrap-retry cfg $ mdata)
|
||||
(climit/wrap cfg $ mdata)
|
||||
(rlimit/wrap cfg $ mdata)
|
||||
(wrap-audit cfg $ mdata)
|
||||
(wrap-spec-conform cfg $ mdata)
|
||||
(wrap-output-validation cfg $ mdata)
|
||||
(wrap-params-validation cfg $ mdata)
|
||||
(wrap-authentication cfg $ mdata)))
|
||||
|
||||
(defn- wrap
|
||||
[cfg f mdata]
|
||||
(let [f (as-> f $
|
||||
(wrap-dispatch cfg $ mdata)
|
||||
(cond/wrap cfg $ mdata)
|
||||
(retry/wrap-retry cfg $ mdata)
|
||||
(wrap-metrics cfg $ mdata)
|
||||
(climit/wrap cfg $ mdata)
|
||||
(rlimit/wrap cfg $ mdata)
|
||||
(wrap-audit cfg $ mdata))
|
||||
|
||||
spec (or (::sv/spec mdata) (s/spec any?))
|
||||
auth? (::auth mdata true)]
|
||||
|
||||
|
||||
(l/debug :hint "register method" :name (::sv/name mdata))
|
||||
(with-meta
|
||||
(fn [params]
|
||||
;; Raise authentication error when rpc method requires auth but
|
||||
;; no profile-id is found in the request.
|
||||
(let [profile-id (if (= "command" (::type cfg))
|
||||
(::profile-id params)
|
||||
(:profile-id params))]
|
||||
(p/do!
|
||||
(if (and auth? (not (uuid? profile-id)))
|
||||
(ex/raise :type :authentication
|
||||
:code :authentication-required
|
||||
:hint "authentication required for this endpoint")
|
||||
(let [params (us/conform spec params)]
|
||||
(f cfg params))))))
|
||||
mdata)))
|
||||
(l/debug :hint "register method" :name (::sv/name mdata))
|
||||
(let [f (wrap-all cfg f mdata)]
|
||||
(partial f cfg)))
|
||||
|
||||
(defn- process-method
|
||||
[cfg vfn]
|
||||
(let [mdata (meta vfn)]
|
||||
[(keyword (::sv/name mdata))
|
||||
(wrap cfg vfn mdata)]))
|
||||
|
||||
(defn- resolve-query-methods
|
||||
[cfg]
|
||||
(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.profile
|
||||
'app.rpc.queries.viewer
|
||||
'app.rpc.queries.fonts)
|
||||
(map (partial process-method cfg))
|
||||
(into {}))))
|
||||
|
||||
(defn- resolve-mutation-methods
|
||||
[cfg]
|
||||
(let [cfg (assoc cfg ::type "mutation" ::metrics-id :rpc-mutation-timing)]
|
||||
(->> (sv/scan-ns 'app.rpc.mutations.media
|
||||
'app.rpc.mutations.profile
|
||||
'app.rpc.mutations.files
|
||||
'app.rpc.mutations.projects
|
||||
'app.rpc.mutations.teams
|
||||
'app.rpc.mutations.fonts
|
||||
'app.rpc.mutations.share-link)
|
||||
(map (partial process-method cfg))
|
||||
(into {}))))
|
||||
[cfg [vfn mdata]]
|
||||
[(keyword (::sv/name mdata)) [mdata (wrap cfg vfn mdata)]])
|
||||
|
||||
(defn- resolve-command-methods
|
||||
[cfg]
|
||||
(let [cfg (assoc cfg ::type "command" ::metrics-id :rpc-command-timing)]
|
||||
(->> (sv/scan-ns 'app.rpc.commands.binfile
|
||||
'app.rpc.commands.comments
|
||||
'app.rpc.commands.management
|
||||
'app.rpc.commands.verify-token
|
||||
'app.rpc.commands.search
|
||||
'app.rpc.commands.media
|
||||
'app.rpc.commands.teams
|
||||
'app.rpc.commands.auth
|
||||
'app.rpc.commands.ldap
|
||||
'app.rpc.commands.demo
|
||||
'app.rpc.commands.webhooks
|
||||
'app.rpc.commands.audit
|
||||
'app.rpc.commands.files
|
||||
'app.rpc.commands.files.update
|
||||
'app.rpc.commands.files.create
|
||||
'app.rpc.commands.files.temp)
|
||||
(->> (sv/scan-ns
|
||||
'app.rpc.commands.access-token
|
||||
'app.rpc.commands.audit
|
||||
'app.rpc.commands.auth
|
||||
'app.rpc.commands.feedback
|
||||
'app.rpc.commands.fonts
|
||||
'app.rpc.commands.binfile
|
||||
'app.rpc.commands.comments
|
||||
'app.rpc.commands.demo
|
||||
'app.rpc.commands.files
|
||||
'app.rpc.commands.files-create
|
||||
'app.rpc.commands.files-share
|
||||
'app.rpc.commands.files-temp
|
||||
'app.rpc.commands.files-update
|
||||
'app.rpc.commands.files-thumbnails
|
||||
'app.rpc.commands.ldap
|
||||
'app.rpc.commands.management
|
||||
'app.rpc.commands.media
|
||||
'app.rpc.commands.profile
|
||||
'app.rpc.commands.projects
|
||||
'app.rpc.commands.search
|
||||
'app.rpc.commands.teams
|
||||
'app.rpc.commands.verify-token
|
||||
'app.rpc.commands.viewer
|
||||
'app.rpc.commands.webhooks)
|
||||
(map (partial process-method cfg))
|
||||
(into {}))))
|
||||
|
||||
(s/def ::ldap (s/nilable map?))
|
||||
(s/def ::msgbus ::mbus/msgbus)
|
||||
(s/def ::climit (s/nilable ::climit/climit))
|
||||
(s/def ::rlimit (s/nilable ::rlimit/rlimit))
|
||||
|
||||
(s/def ::public-uri ::us/not-empty-string)
|
||||
(s/def ::sprops map?)
|
||||
|
||||
(defmethod ig/pre-init-spec ::methods [_]
|
||||
(s/keys :req [::http.client/client
|
||||
(s/keys :req [::session/manager
|
||||
::http.client/client
|
||||
::db/pool
|
||||
::mbus/msgbus
|
||||
::ldap/provider
|
||||
::sto/storage
|
||||
::mtx/metrics
|
||||
::main/props
|
||||
::wrk/executor]
|
||||
:req-un [::sto/storage
|
||||
::http.session/session
|
||||
::sprops
|
||||
::public-uri
|
||||
::msgbus
|
||||
::rlimit
|
||||
::climit
|
||||
::wrk/executor
|
||||
::mtx/metrics
|
||||
::db/pool]))
|
||||
:opt [::climit
|
||||
::rlimit]
|
||||
:req-un [::db/pool]))
|
||||
|
||||
(defmethod ig/init-key ::methods
|
||||
[_ cfg]
|
||||
{:mutations (resolve-mutation-methods cfg)
|
||||
:queries (resolve-query-methods cfg)
|
||||
:commands (resolve-command-methods cfg)})
|
||||
|
||||
(s/def ::mutations
|
||||
(s/map-of keyword? fn?))
|
||||
|
||||
(s/def ::queries
|
||||
(s/map-of keyword? fn?))
|
||||
|
||||
(s/def ::commands
|
||||
(s/map-of keyword? fn?))
|
||||
(let [cfg (d/without-nils cfg)]
|
||||
(resolve-command-methods cfg)))
|
||||
|
||||
(s/def ::methods
|
||||
(s/keys :req-un [::mutations
|
||||
::queries
|
||||
::commands]))
|
||||
(s/map-of keyword? (s/tuple map? fn?)))
|
||||
|
||||
(s/def ::routes vector?)
|
||||
|
||||
(defmethod ig/pre-init-spec ::routes [_]
|
||||
(s/keys :req-un [::methods]))
|
||||
(s/keys :req [::methods
|
||||
::db/pool
|
||||
::main/props
|
||||
::wrk/executor
|
||||
::session/manager]))
|
||||
|
||||
(defmethod ig/init-key ::routes
|
||||
[_ {:keys [methods] :as cfg}]
|
||||
[["/rpc"
|
||||
["/command/:type" {:handler (partial rpc-command-handler (:commands methods))}]
|
||||
["/query/:type" {:handler (partial rpc-query-handler (:queries methods))}]
|
||||
["/mutation/:type" {:handler (partial rpc-mutation-handler (:mutations methods))
|
||||
:allowed-methods #{:post}}]]])
|
||||
|
||||
[_ {:keys [::methods] :as cfg}]
|
||||
(let [methods (update-vals methods peek)]
|
||||
[["/rpc" {:middleware [[session/authz cfg]
|
||||
[actoken/authz cfg]]}
|
||||
["/command/:type" {:handler (partial rpc-handler methods)}]]]))
|
||||
|
||||
@@ -6,14 +6,16 @@
|
||||
|
||||
(ns app.rpc.climit
|
||||
"Concurrencly limiter for RPC."
|
||||
(:refer-clojure :exclude [run!])
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.logging :as l]
|
||||
[app.common.spec :as us]
|
||||
[app.config :as cf]
|
||||
[app.metrics :as mtx]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.climit.config :as-alias config]
|
||||
[app.util.cache :as cache]
|
||||
[app.util.services :as-alias sv]
|
||||
[app.util.time :as dt]
|
||||
[app.worker :as-alias wrk]
|
||||
@@ -23,182 +25,200 @@
|
||||
[integrant.core :as ig]
|
||||
[promesa.core :as p]
|
||||
[promesa.exec :as px]
|
||||
[promesa.exec.bulkhead :as pxb])
|
||||
[promesa.exec.bulkhead :as pbh])
|
||||
(:import
|
||||
com.github.benmanes.caffeine.cache.Cache
|
||||
com.github.benmanes.caffeine.cache.CacheLoader
|
||||
com.github.benmanes.caffeine.cache.Caffeine
|
||||
com.github.benmanes.caffeine.cache.RemovalListener))
|
||||
clojure.lang.ExceptionInfo))
|
||||
|
||||
(defn- capacity-exception?
|
||||
[o]
|
||||
(and (ex/ex-info? o)
|
||||
(let [data (ex-data o)]
|
||||
(and (= :bulkhead-error (:type data))
|
||||
(= :capacity-limit-reached (:code data))))))
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn invoke!
|
||||
[limiter f]
|
||||
(->> (px/submit! limiter f)
|
||||
(p/hcat (fn [result cause]
|
||||
(cond
|
||||
(capacity-exception? cause)
|
||||
(p/rejected
|
||||
(ex/error :type :internal
|
||||
:code :concurrency-limit-reached
|
||||
:queue (-> limiter meta :bkey name)
|
||||
:cause cause))
|
||||
(defn- create-bulkhead-cache
|
||||
[{:keys [::wrk/executor]} config]
|
||||
(letfn [(load-fn [key]
|
||||
(let [config (get config (nth key 0))]
|
||||
(l/trace :hint "insert into cache" :key key)
|
||||
(pbh/create :permits (or (:permits config) (:concurrency config))
|
||||
:queue (or (:queue config) (:queue-size config))
|
||||
:timeout (:timeout config)
|
||||
:executor executor
|
||||
:type (:type config :semaphore))))
|
||||
|
||||
(some? cause)
|
||||
(p/rejected cause)
|
||||
(on-remove [_ _ cause]
|
||||
(l/trace :hint "evict from cache" :key key :reason (str cause)))]
|
||||
|
||||
:else
|
||||
(p/resolved result))))))
|
||||
(cache/create :executor :same-thread
|
||||
:on-remove on-remove
|
||||
:keepalive "5m"
|
||||
:load-fn load-fn)))
|
||||
|
||||
(defn- create-limiter
|
||||
[{:keys [executor metrics concurrency queue-size bkey skey]}]
|
||||
(let [labels (into-array String [(name bkey)])
|
||||
on-queue (fn [instance]
|
||||
(l/trace :hint "enqueued"
|
||||
:key (name bkey)
|
||||
:skey (str skey)
|
||||
:queue-size (get instance ::pxb/current-queue-size)
|
||||
:concurrency (get instance ::pxb/current-concurrency))
|
||||
(mtx/run! metrics
|
||||
:id :rpc-climit-queue-size
|
||||
:val (get instance ::pxb/current-queue-size)
|
||||
:labels labels)
|
||||
(mtx/run! metrics
|
||||
:id :rpc-climit-concurrency
|
||||
:val (get instance ::pxb/current-concurrency)
|
||||
:labels labels))
|
||||
|
||||
on-run (fn [instance task]
|
||||
(let [elapsed (- (inst-ms (dt/now))
|
||||
(inst-ms task))]
|
||||
(l/trace :hint "execute"
|
||||
:key (name bkey)
|
||||
:skey (str skey)
|
||||
:elapsed (str elapsed "ms"))
|
||||
(mtx/run! metrics
|
||||
:id :rpc-climit-timing
|
||||
:val elapsed
|
||||
:labels labels)
|
||||
(mtx/run! metrics
|
||||
:id :rpc-climit-queue-size
|
||||
:val (get instance ::pxb/current-queue-size)
|
||||
:labels labels)
|
||||
(mtx/run! metrics
|
||||
:id :rpc-climit-concurrency
|
||||
:val (get instance ::pxb/current-concurrency)
|
||||
:labels labels)))
|
||||
|
||||
options {:executor executor
|
||||
:concurrency concurrency
|
||||
:queue-size (or queue-size Integer/MAX_VALUE)
|
||||
:on-queue on-queue
|
||||
:on-run on-run}]
|
||||
|
||||
(-> (pxb/create options)
|
||||
(vary-meta assoc :bkey bkey :skey skey))))
|
||||
|
||||
(defn- create-cache
|
||||
[{:keys [executor] :as params} config]
|
||||
(let [listener (reify RemovalListener
|
||||
(onRemoval [_ key _val cause]
|
||||
(l/trace :hint "cache: remove" :key key :reason (str cause))))
|
||||
|
||||
loader (reify CacheLoader
|
||||
(load [_ key]
|
||||
(let [[bkey skey] key]
|
||||
(when-let [config (get config bkey)]
|
||||
(-> (merge params config)
|
||||
(assoc :bkey bkey)
|
||||
(assoc :skey skey)
|
||||
(create-limiter))))))]
|
||||
|
||||
(.. (Caffeine/newBuilder)
|
||||
(weakValues)
|
||||
(executor executor)
|
||||
(removalListener listener)
|
||||
(build loader))))
|
||||
|
||||
(defprotocol IConcurrencyManager)
|
||||
|
||||
(s/def ::concurrency ::us/integer)
|
||||
(s/def ::queue-size ::us/integer)
|
||||
(s/def ::config/permits ::us/integer)
|
||||
(s/def ::config/queue ::us/integer)
|
||||
(s/def ::config/timeout ::us/integer)
|
||||
(s/def ::config
|
||||
(s/map-of keyword?
|
||||
(s/keys :req-un [::concurrency]
|
||||
:opt-un [::queue-size])))
|
||||
(s/keys :opt-un [::config/permits
|
||||
::config/queue
|
||||
::config/timeout])))
|
||||
|
||||
(defmethod ig/prep-key ::rpc/climit
|
||||
[_ cfg]
|
||||
(merge {:path (cf/get :rpc-climit-config)}
|
||||
(d/without-nils cfg)))
|
||||
(assoc cfg ::path (cf/get :rpc-climit-config)))
|
||||
|
||||
(s/def ::path ::fs/path)
|
||||
(defmethod ig/pre-init-spec ::rpc/climit [_]
|
||||
(s/keys :req-un [::wrk/executor ::mtx/metrics ::fs/path]))
|
||||
(s/keys :req [::wrk/executor ::mtx/metrics ::path]))
|
||||
|
||||
(defmethod ig/init-key ::rpc/climit
|
||||
[_ {:keys [path] :as params}]
|
||||
[_ {:keys [::path ::mtx/metrics ::wrk/executor] :as cfg}]
|
||||
(when (contains? cf/flags :rpc-climit)
|
||||
(if-let [config (some->> path slurp edn/read-string)]
|
||||
(do
|
||||
(l/info :hint "initializing concurrency limit" :config (str path))
|
||||
(us/verify! ::config config)
|
||||
(when-let [params (some->> path slurp edn/read-string)]
|
||||
(l/info :hint "initializing concurrency limit" :config (str path))
|
||||
(us/verify! ::config params)
|
||||
{::cache (create-bulkhead-cache cfg params)
|
||||
::config params
|
||||
::wrk/executor executor
|
||||
::mtx/metrics metrics})))
|
||||
|
||||
(let [cache (create-cache params config)]
|
||||
^{::cache cache}
|
||||
(reify
|
||||
IConcurrencyManager
|
||||
clojure.lang.IDeref
|
||||
(deref [_] config)
|
||||
(s/def ::cache cache/cache?)
|
||||
(s/def ::instance
|
||||
(s/keys :req [::cache ::config ::wrk/executor]))
|
||||
|
||||
clojure.lang.ILookup
|
||||
(valAt [_ key]
|
||||
(let [key (if (vector? key) key [key])]
|
||||
(.get ^Cache cache key))))))
|
||||
|
||||
(l/warn :hint "unable to load configuration" :config (str path)))))
|
||||
|
||||
|
||||
(s/def ::climit #(satisfies? IConcurrencyManager %))
|
||||
(s/def ::rpc/climit
|
||||
(s/nilable ::instance))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; PUBLIC API
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn invoke!
|
||||
[cache metrics id key f]
|
||||
(let [limiter (cache/get cache [id key])
|
||||
tpoint (dt/tpoint)
|
||||
labels (into-array String [(name id)])
|
||||
|
||||
wrapped
|
||||
(fn []
|
||||
(let [elapsed (tpoint)
|
||||
stats (pbh/get-stats limiter)]
|
||||
(l/trace :hint "executed"
|
||||
:id (name id)
|
||||
:key key
|
||||
:fnh (hash f)
|
||||
:permits (:permits stats)
|
||||
:queue (:queue stats)
|
||||
:max-permits (:max-permits stats)
|
||||
:max-queue (:max-queue stats)
|
||||
:elapsed (dt/format-duration elapsed))
|
||||
(mtx/run! metrics
|
||||
:id :rpc-climit-timing
|
||||
:val (inst-ms elapsed)
|
||||
:labels labels)
|
||||
(try
|
||||
(f)
|
||||
(finally
|
||||
(let [elapsed (tpoint)]
|
||||
(l/trace :hint "finished"
|
||||
:id (name id)
|
||||
:key key
|
||||
:fnh (hash f)
|
||||
:permits (:permits stats)
|
||||
:queue (:queue stats)
|
||||
:max-permits (:max-permits stats)
|
||||
:max-queue (:max-queue stats)
|
||||
:elapsed (dt/format-duration elapsed)))))))
|
||||
measure!
|
||||
(fn [stats]
|
||||
(mtx/run! metrics
|
||||
:id :rpc-climit-queue
|
||||
:val (:queue stats)
|
||||
:labels labels)
|
||||
(mtx/run! metrics
|
||||
:id :rpc-climit-permits
|
||||
:val (:permits stats)
|
||||
:labels labels))]
|
||||
|
||||
(try
|
||||
(let [stats (pbh/get-stats limiter)]
|
||||
(measure! stats)
|
||||
(l/trace :hint "enqueued"
|
||||
:id (name id)
|
||||
:key key
|
||||
:fnh (hash f)
|
||||
:permits (:permits stats)
|
||||
:queue (:queue stats)
|
||||
:max-permits (:max-permits stats)
|
||||
:max-queue (:max-queue stats))
|
||||
(pbh/invoke! limiter wrapped))
|
||||
(catch ExceptionInfo cause
|
||||
(let [{:keys [type code]} (ex-data cause)]
|
||||
(if (= :bulkhead-error type)
|
||||
(ex/raise :type :concurrency-limit
|
||||
:code code
|
||||
:hint "concurrency limit reached")
|
||||
(throw cause))))
|
||||
|
||||
(finally
|
||||
(measure! (pbh/get-stats limiter))))))
|
||||
|
||||
|
||||
(defn run!
|
||||
[{:keys [::id ::cache ::mtx/metrics]} f]
|
||||
(if (and cache id)
|
||||
(invoke! cache metrics id nil f)
|
||||
(f)))
|
||||
|
||||
(defn submit!
|
||||
[{:keys [::id ::cache ::wrk/executor ::mtx/metrics]} f]
|
||||
(let [f (partial px/submit! executor (px/wrap-bindings f))]
|
||||
(if (and cache id)
|
||||
(p/await! (invoke! cache metrics id nil f))
|
||||
(p/await! (f)))))
|
||||
|
||||
(defn configure
|
||||
([{:keys [::rpc/climit]} id]
|
||||
(us/assert! ::rpc/climit climit)
|
||||
(assoc climit ::id id))
|
||||
([{:keys [::rpc/climit]} id executor]
|
||||
(us/assert! ::rpc/climit climit)
|
||||
(-> climit
|
||||
(assoc ::id id)
|
||||
(assoc ::wrk/executor executor))))
|
||||
|
||||
(defmacro with-dispatch!
|
||||
"Dispatch blocking operation to a separated thread protected with the
|
||||
specified concurrency limiter. If climit is not active, the function
|
||||
will be scheduled to execute without concurrency monitoring."
|
||||
[instance & body]
|
||||
(if (vector? instance)
|
||||
`(-> (app.rpc.climit/configure ~@instance)
|
||||
(app.rpc.climit/run! (^:once fn* [] ~@body)))
|
||||
`(run! ~instance (^:once fn* [] ~@body))))
|
||||
|
||||
(defmacro with-dispatch
|
||||
[lim & body]
|
||||
`(if ~lim
|
||||
(invoke! ~lim (^:once fn [] (p/wrap (do ~@body))))
|
||||
(p/wrap (do ~@body))))
|
||||
"Dispatch blocking operation to a separated thread protected with
|
||||
the specified semaphore.
|
||||
DEPRECATED"
|
||||
[& params]
|
||||
`(with-dispatch! ~@params))
|
||||
|
||||
(def noop-fn (constantly nil))
|
||||
|
||||
(defn wrap
|
||||
[{:keys [climit]} f {:keys [::queue ::key-fn] :as mdata}]
|
||||
(if (and (some? climit)
|
||||
(some? queue))
|
||||
(if-let [config (get @climit queue)]
|
||||
(do
|
||||
[{:keys [::rpc/climit ::mtx/metrics]} f {:keys [::id ::key-fn] :or {key-fn noop-fn} :as mdata}]
|
||||
(if (and (some? climit) (some? id))
|
||||
(if-let [config (get-in climit [::config id])]
|
||||
(let [cache (::cache climit)]
|
||||
(l/debug :hint "wrap: instrumenting method"
|
||||
:limit-name (name queue)
|
||||
:limit (name id)
|
||||
:service-name (::sv/name mdata)
|
||||
:queue-size (or (:queue-size config) Integer/MAX_VALUE)
|
||||
:concurrency (:concurrency config)
|
||||
:timeout (:timeout config)
|
||||
:permits (:permits config)
|
||||
:queue (:queue config)
|
||||
:keyed? (some? key-fn))
|
||||
(if (some? key-fn)
|
||||
(fn [cfg params]
|
||||
(let [key [queue (key-fn params)]
|
||||
lim (get climit key)]
|
||||
(invoke! lim (partial f cfg params))))
|
||||
(fn [cfg params]
|
||||
(invoke! cache metrics id (key-fn params) (partial f cfg params))))
|
||||
|
||||
(let [lim (get climit queue)]
|
||||
(fn [cfg params]
|
||||
(invoke! lim (partial f cfg params))))))
|
||||
(do
|
||||
(l/warn :hint "wrap: no config found"
|
||||
:queue (name queue)
|
||||
:service (::sv/name mdata))
|
||||
(l/warn :hint "no config found for specified queue" :id id)
|
||||
f))
|
||||
|
||||
f))
|
||||
|
||||
94
backend/src/app/rpc/commands/access_token.clj
Normal file
94
backend/src/app/rpc/commands/access_token.clj
Normal file
@@ -0,0 +1,94 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC
|
||||
|
||||
(ns app.rpc.commands.access-token
|
||||
(:require
|
||||
[app.common.spec :as us]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.db :as db]
|
||||
[app.main :as-alias main]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.rpc.quotes :as quotes]
|
||||
[app.tokens :as tokens]
|
||||
[app.util.services :as sv]
|
||||
[app.util.time :as dt]
|
||||
[clojure.spec.alpha :as s]))
|
||||
|
||||
(defn- decode-row
|
||||
[row]
|
||||
(dissoc row :perms))
|
||||
|
||||
(defn create-access-token
|
||||
[{:keys [::db/conn ::main/props]} profile-id name expiration]
|
||||
(let [created-at (dt/now)
|
||||
token-id (uuid/next)
|
||||
token (tokens/generate props {:iss "access-token"
|
||||
:tid token-id
|
||||
:iat created-at})
|
||||
|
||||
expires-at (some-> expiration dt/in-future)]
|
||||
|
||||
(db/insert! conn :access-token
|
||||
{:id token-id
|
||||
:name name
|
||||
:token token
|
||||
:profile-id profile-id
|
||||
:created-at created-at
|
||||
:updated-at created-at
|
||||
:expires-at expires-at
|
||||
:perms (db/create-array conn "text" [])})))
|
||||
|
||||
|
||||
(defn repl-create-access-token
|
||||
[{:keys [::db/pool] :as system} profile-id name expiration]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [props (:app.setup/props system)]
|
||||
(create-access-token {::db/conn conn ::main/props props}
|
||||
profile-id
|
||||
name
|
||||
expiration))))
|
||||
|
||||
(s/def ::name ::us/not-empty-string)
|
||||
(s/def ::expiration ::dt/duration)
|
||||
|
||||
(s/def ::create-access-token
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::name]
|
||||
:opt-un [::expiration]))
|
||||
|
||||
(sv/defmethod ::create-access-token
|
||||
{::doc/added "1.18"}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id name expiration]}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [cfg (assoc cfg ::db/conn conn)]
|
||||
(quotes/check-quote! conn
|
||||
{::quotes/id ::quotes/access-tokens-per-profile
|
||||
::quotes/profile-id profile-id})
|
||||
(-> (create-access-token cfg profile-id name expiration)
|
||||
(decode-row)))))
|
||||
|
||||
(s/def ::delete-access-token
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::us/id]))
|
||||
|
||||
(sv/defmethod ::delete-access-token
|
||||
{::doc/added "1.18"}
|
||||
[{:keys [::db/pool]} {:keys [::rpc/profile-id id]}]
|
||||
(db/delete! pool :access-token {:id id :profile-id profile-id})
|
||||
nil)
|
||||
|
||||
(s/def ::get-access-tokens
|
||||
(s/keys :req [::rpc/profile-id]))
|
||||
|
||||
(sv/defmethod ::get-access-tokens
|
||||
{::doc/added "1.18"}
|
||||
[{:keys [::db/pool]} {:keys [::rpc/profile-id]}]
|
||||
(->> (db/query pool :access-token
|
||||
{:profile-id profile-id}
|
||||
{:order-by [[:expires-at :asc] [:created-at :asc]]
|
||||
:columns [:id :name :perms :created-at :updated-at :expires-at]})
|
||||
(mapv decode-row)))
|
||||
@@ -9,7 +9,7 @@
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.logging :as l]
|
||||
[app.common.spec :as us]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
@@ -19,12 +19,7 @@
|
||||
[app.rpc.climit :as-alias climit]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.rpc.helpers :as rph]
|
||||
[app.util.services :as sv]
|
||||
[app.util.time :as dt]
|
||||
[app.worker :as wrk]
|
||||
[clojure.spec.alpha :as s]
|
||||
[promesa.core :as p]
|
||||
[promesa.exec :as px]))
|
||||
[app.util.services :as sv]))
|
||||
|
||||
(defn- event->row [event]
|
||||
[(uuid/next)
|
||||
@@ -42,8 +37,9 @@
|
||||
:profile-id :ip-addr :props :context])
|
||||
|
||||
(defn- handle-events
|
||||
[{:keys [::db/pool]} {:keys [::rpc/profile-id events ::http/request] :as params}]
|
||||
(let [ip-addr (audit/parse-client-ip request)
|
||||
[{:keys [::db/pool]} {:keys [::rpc/profile-id events] :as params}]
|
||||
(let [request (-> params meta ::http/request)
|
||||
ip-addr (audit/parse-client-ip request)
|
||||
xform (comp
|
||||
(map #(assoc % :profile-id profile-id))
|
||||
(map #(assoc % :ip-addr ip-addr))
|
||||
@@ -54,34 +50,37 @@
|
||||
(when (seq events)
|
||||
(db/insert-multi! pool :audit-log event-columns events))))
|
||||
|
||||
(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?))
|
||||
(def schema:event
|
||||
[:map {:title "Event"}
|
||||
[:name [:string {:max 250}]]
|
||||
[:type [:string {:max 250}]]
|
||||
[:props
|
||||
[:map-of :keyword :any]]
|
||||
[:context {:optional true}
|
||||
[:map-of :keyword :any]]])
|
||||
|
||||
(s/def ::event
|
||||
(s/keys :req-un [::type ::name ::props ::timestamp]
|
||||
:opt-un [::context]))
|
||||
|
||||
(s/def ::events (s/every ::event))
|
||||
|
||||
(s/def ::push-audit-events
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::events]))
|
||||
(def schema:push-audit-events
|
||||
[:map {:title "push-audit-events"}
|
||||
[:events [:vector schema:event]]])
|
||||
|
||||
(sv/defmethod ::push-audit-events
|
||||
{::climit/queue :push-audit-events
|
||||
{::climit/id :submit-audit-events-by-profile
|
||||
::climit/key-fn ::rpc/profile-id
|
||||
::sm/params schema:push-audit-events
|
||||
::audit/skip true
|
||||
::doc/added "1.17"}
|
||||
[{:keys [::db/pool ::wrk/executor] :as cfg} params]
|
||||
[{:keys [::db/pool] :as cfg} params]
|
||||
(if (or (db/read-only? pool)
|
||||
(not (contains? cf/flags :audit-log)))
|
||||
(do
|
||||
(l/warn :hint "audit: http handler disabled or db is read-only")
|
||||
(rph/wrap nil))
|
||||
|
||||
(->> (px/submit! executor #(handle-events cfg params))
|
||||
(p/fmap (constantly nil)))))
|
||||
(do
|
||||
(try
|
||||
(handle-events cfg params)
|
||||
(catch Throwable cause
|
||||
(l/error :hint "unexpected error on persisting audit events from frontend"
|
||||
:cause cause)))
|
||||
|
||||
(rph/wrap nil))))
|
||||
|
||||
@@ -8,68 +8,37 @@
|
||||
(:require
|
||||
[app.auth :as auth]
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.spec :as us]
|
||||
[app.common.logging :as l]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.emails :as eml]
|
||||
[app.email :as eml]
|
||||
[app.http.session :as session]
|
||||
[app.loggers.audit :as audit]
|
||||
[app.main :as-alias main]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.climit :as climit]
|
||||
[app.rpc.commands.profile :as profile]
|
||||
[app.rpc.commands.teams :as teams]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.rpc.helpers :as rph]
|
||||
[app.rpc.queries.profile :as profile]
|
||||
[app.tokens :as tokens]
|
||||
[app.util.services :as sv]
|
||||
[app.util.time :as dt]
|
||||
[clojure.spec.alpha :as s]
|
||||
[cuerdas.core :as str]))
|
||||
|
||||
(s/def ::email ::us/email)
|
||||
(s/def ::fullname ::us/not-empty-string)
|
||||
(s/def ::lang ::us/string)
|
||||
(s/def ::path ::us/string)
|
||||
(s/def ::password ::us/not-empty-string)
|
||||
(s/def ::old-password ::us/not-empty-string)
|
||||
(s/def ::theme ::us/string)
|
||||
(s/def ::invitation-token ::us/not-empty-string)
|
||||
(s/def ::token ::us/not-empty-string)
|
||||
(def schema:password
|
||||
[::sm/word-string {:max 500}])
|
||||
|
||||
;; ---- HELPERS
|
||||
|
||||
(defn email-domain-in-whitelist?
|
||||
"Returns true if email's domain is in the given whitelist or if
|
||||
given whitelist is an empty string."
|
||||
[domains email]
|
||||
(if (or (empty? domains)
|
||||
(nil? domains))
|
||||
true
|
||||
(let [[_ candidate] (-> (str/lower email)
|
||||
(str/split #"@" 2))]
|
||||
(contains? domains candidate))))
|
||||
|
||||
(def ^:private sql:profile-existence
|
||||
"select exists (select * from profile
|
||||
where email = ?
|
||||
and deleted_at is null) as val")
|
||||
|
||||
(defn check-profile-existence!
|
||||
[conn {:keys [email] :as params}]
|
||||
(let [email (str/lower email)
|
||||
result (db/exec-one! conn [sql:profile-existence email])]
|
||||
(when (:val result)
|
||||
(ex/raise :type :validation
|
||||
:code :email-already-exists))
|
||||
params))
|
||||
(def schema:token
|
||||
[::sm/word-string {:max 6000}])
|
||||
|
||||
;; ---- COMMAND: login with password
|
||||
|
||||
(defn login-with-password
|
||||
[{:keys [::db/pool session] :as cfg} {:keys [email password] :as params}]
|
||||
[{:keys [::db/pool] :as cfg} {:keys [email password] :as params}]
|
||||
|
||||
(when-not (or (contains? cf/flags :login)
|
||||
(contains? cf/flags :login-with-password))
|
||||
@@ -77,14 +46,18 @@
|
||||
:code :login-disabled
|
||||
:hint "login is disabled in this instance"))
|
||||
|
||||
(letfn [(check-password [profile password]
|
||||
(when (= (:password profile) "!")
|
||||
(letfn [(check-password [conn profile password]
|
||||
(if (= (:password profile) "!")
|
||||
(ex/raise :type :validation
|
||||
:code :account-without-password
|
||||
:hint "the current account does not have password"))
|
||||
(:valid (auth/verify-password password (:password profile))))
|
||||
:hint "the current account does not have password")
|
||||
(let [result (profile/verify-password cfg password (:password profile))]
|
||||
(when (:update result)
|
||||
(l/trace :hint "updating profile password" :id (:id profile) :email (:email profile))
|
||||
(profile/update-profile-password! conn (assoc profile :password password)))
|
||||
(:valid result))))
|
||||
|
||||
(validate-profile [profile]
|
||||
(validate-profile [conn profile]
|
||||
(when-not profile
|
||||
(ex/raise :type :validation
|
||||
:code :wrong-credentials))
|
||||
@@ -94,7 +67,7 @@
|
||||
(when (:is-blocked profile)
|
||||
(ex/raise :type :restriction
|
||||
:code :profile-blocked))
|
||||
(when-not (check-password profile password)
|
||||
(when-not (check-password conn profile password)
|
||||
(ex/raise :type :validation
|
||||
:code :wrong-credentials))
|
||||
(when-let [deleted-at (:deleted-at profile)]
|
||||
@@ -105,11 +78,9 @@
|
||||
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)
|
||||
(profile/decode-profile-row))
|
||||
(let [profile (->> (profile/get-profile-by-email conn email)
|
||||
(validate-profile conn)
|
||||
(profile/strip-private-attrs))
|
||||
|
||||
invitation (when-let [token (:invitation-token params)]
|
||||
(tokens/verify (::main/props cfg) {:token token :iss :team-invitation}))
|
||||
@@ -122,34 +93,32 @@
|
||||
(assoc profile :is-admin (let [admins (cf/get :admins)]
|
||||
(contains? admins (:email profile)))))]
|
||||
(-> response
|
||||
(rph/with-transform (session/create-fn session (:id profile)))
|
||||
(rph/with-transform (session/create-fn cfg (:id profile)))
|
||||
(rph/with-meta {::audit/props (audit/profile->props profile)
|
||||
::audit/profile-id (:id profile)}))))))
|
||||
|
||||
(s/def ::scope ::us/string)
|
||||
(s/def ::login-with-password
|
||||
(s/keys :req-un [::email ::password]
|
||||
:opt-un [::invitation-token ::scope]))
|
||||
(def schema:login-with-password
|
||||
[:map {:title "login-with-password"}
|
||||
[:email ::sm/email]
|
||||
[:password schema:password]
|
||||
[:invitation-token {:optional true} schema:token]])
|
||||
|
||||
(sv/defmethod ::login-with-password
|
||||
"Performs authentication using penpot password."
|
||||
{::rpc/auth false
|
||||
::climit/queue :auth
|
||||
::doc/added "1.15"}
|
||||
::doc/added "1.15"
|
||||
::sm/params schema:login-with-password}
|
||||
[cfg params]
|
||||
(login-with-password cfg params))
|
||||
|
||||
;; ---- COMMAND: Logout
|
||||
|
||||
(s/def ::logout
|
||||
(s/keys :opt [::rpc/profile-id]))
|
||||
|
||||
(sv/defmethod ::logout
|
||||
"Clears the authentication cookie and logout the current session."
|
||||
{::rpc/auth false
|
||||
::doc/added "1.15"}
|
||||
[{:keys [session] :as cfg} _]
|
||||
(rph/with-transform {} (session/delete-fn session)))
|
||||
[cfg _]
|
||||
(rph/with-transform {} (session/delete-fn cfg)))
|
||||
|
||||
;; ---- COMMAND: Recover Profile
|
||||
|
||||
@@ -160,7 +129,7 @@
|
||||
(:profile-id tdata)))
|
||||
|
||||
(update-password [conn profile-id]
|
||||
(let [pwd (auth/derive-password password)]
|
||||
(let [pwd (profile/derive-password cfg password)]
|
||||
(db/update! conn :profile {:password pwd} {:id profile-id})))]
|
||||
|
||||
(db/with-atomic [conn pool]
|
||||
@@ -168,14 +137,15 @@
|
||||
(update-password conn))
|
||||
nil)))
|
||||
|
||||
(s/def ::token ::us/not-empty-string)
|
||||
(s/def ::recover-profile
|
||||
(s/keys :req-un [::token ::password]))
|
||||
(def schema:recover-profile
|
||||
[:map {:title "recover-profile"}
|
||||
[:token schema:token]
|
||||
[:password schema:password]])
|
||||
|
||||
(sv/defmethod ::recover-profile
|
||||
{::rpc/auth false
|
||||
::climit/queue :auth
|
||||
::doc/added "1.15"}
|
||||
::doc/added "1.15"
|
||||
::sm/params schema:recover-profile}
|
||||
[cfg params]
|
||||
(recover-profile cfg params))
|
||||
|
||||
@@ -185,19 +155,20 @@
|
||||
[{:keys [::db/pool] :as cfg} params]
|
||||
|
||||
(when-not (contains? cf/flags :registration)
|
||||
(if-not (contains? params :invitation-token)
|
||||
(when-not (contains? params :invitation-token)
|
||||
(ex/raise :type :restriction
|
||||
:code :registration-disabled)
|
||||
(let [invitation (tokens/verify (::main/props cfg) {:token (:invitation-token params) :iss :team-invitation})]
|
||||
(when-not (= (:email params) (:member-email invitation))
|
||||
(ex/raise :type :restriction
|
||||
:code :email-does-not-match-invitation
|
||||
:hint "email should match the invitation")))))
|
||||
:code :registration-disabled)))
|
||||
|
||||
(when-let [domains (cf/get :registration-domain-whitelist)]
|
||||
(when-not (email-domain-in-whitelist? domains (:email params))
|
||||
(ex/raise :type :validation
|
||||
:code :email-domain-is-not-allowed)))
|
||||
(when (contains? params :invitation-token)
|
||||
(let [invitation (tokens/verify (::main/props cfg) {:token (:invitation-token params) :iss :team-invitation})]
|
||||
(when-not (= (:email params) (:member-email invitation))
|
||||
(ex/raise :type :restriction
|
||||
:code :email-does-not-match-invitation
|
||||
:hint "email should match the invitation"))))
|
||||
|
||||
(when-not (auth/email-domain-in-whitelist? (:email params))
|
||||
(ex/raise :type :validation
|
||||
:code :email-domain-is-not-allowed))
|
||||
|
||||
;; Don't allow proceed in preparing registration if the profile is
|
||||
;; already reported as spammer.
|
||||
@@ -226,7 +197,7 @@
|
||||
|
||||
(validate-register-attempt! cfg params)
|
||||
|
||||
(let [profile (when-let [profile (profile/retrieve-profile-data-by-email pool (:email params))]
|
||||
(let [profile (when-let [profile (profile/get-profile-by-email pool (:email params))]
|
||||
(cond
|
||||
(:is-blocked profile)
|
||||
(ex/raise :type :restriction
|
||||
@@ -255,22 +226,26 @@
|
||||
(with-meta {:token token}
|
||||
{::audit/profile-id uuid/zero})))
|
||||
|
||||
(s/def ::prepare-register-profile
|
||||
(s/keys :req-un [::email ::password]
|
||||
:opt-un [::invitation-token]))
|
||||
(def schema:prepare-register-profile
|
||||
[:map {:title "prepare-register-profile"}
|
||||
[:email ::sm/email]
|
||||
[:password schema:password]
|
||||
[:invitation-token {:optional true} schema:token]])
|
||||
|
||||
(sv/defmethod ::prepare-register-profile
|
||||
{::rpc/auth false
|
||||
::doc/added "1.15"}
|
||||
::doc/added "1.15"
|
||||
::sm/params schema:prepare-register-profile}
|
||||
[cfg params]
|
||||
(prepare-register cfg params))
|
||||
|
||||
;; ---- COMMAND: Register Profile
|
||||
|
||||
(defn create-profile
|
||||
(defn create-profile!
|
||||
"Create the profile entry on the database with limited set of input
|
||||
attrs (all the other attrs are filled with default values)."
|
||||
[conn params]
|
||||
[conn {:keys [email] :as params}]
|
||||
(dm/assert! ::sm/email email)
|
||||
(let [id (or (:id params) (uuid/next))
|
||||
props (-> (audit/extract-utm-params params)
|
||||
(merge (:props params))
|
||||
@@ -279,9 +254,7 @@
|
||||
:nudge {:big 10 :small 1}})
|
||||
(db/tjson))
|
||||
|
||||
password (if-let [password (:password params)]
|
||||
(auth/derive-password password)
|
||||
"!")
|
||||
password (or (:password params) "!")
|
||||
|
||||
locale (:locale params)
|
||||
locale (when (and (string? locale) (not (str/blank? locale)))
|
||||
@@ -291,7 +264,7 @@
|
||||
is-demo (:is-demo params false)
|
||||
is-muted (:is-muted params false)
|
||||
is-active (:is-active params false)
|
||||
email (str/lower (:email params))
|
||||
email (str/lower email)
|
||||
|
||||
params {:id id
|
||||
:fullname (:fullname params)
|
||||
@@ -306,7 +279,7 @@
|
||||
:is-demo is-demo}]
|
||||
(try
|
||||
(-> (db/insert! conn :profile params)
|
||||
(profile/decode-profile-row))
|
||||
(profile/decode-row))
|
||||
(catch org.postgresql.util.PSQLException e
|
||||
(let [state (.getSQLState e)]
|
||||
(if (not= state "23505")
|
||||
@@ -316,15 +289,17 @@
|
||||
:hint "email already exists"
|
||||
:cause e)))))))
|
||||
|
||||
(defn create-profile-relations
|
||||
[conn profile]
|
||||
(let [team (teams/create-team conn {:profile-id (:id profile)
|
||||
(defn create-profile-rels!
|
||||
[conn {:keys [id] :as profile}]
|
||||
(let [team (teams/create-team conn {:profile-id id
|
||||
:name "Default"
|
||||
:is-default true})]
|
||||
(-> profile
|
||||
(profile/strip-private-attrs)
|
||||
(assoc :default-team-id (:id team))
|
||||
(assoc :default-project-id (:default-project-id team)))))
|
||||
(-> (db/update! conn :profile
|
||||
{:default-team-id (:id team)
|
||||
:default-project-id (:default-project-id team)}
|
||||
{:id id})
|
||||
(profile/decode-row))))
|
||||
|
||||
|
||||
(defn send-email-verification!
|
||||
[conn props profile]
|
||||
@@ -348,22 +323,21 @@
|
||||
:extra-data ptoken})))
|
||||
|
||||
(defn register-profile
|
||||
[{:keys [::db/conn session] :as cfg} {:keys [token] :as params}]
|
||||
[{:keys [::db/conn] :as cfg} {:keys [token fullname] :as params}]
|
||||
(let [claims (tokens/verify (::main/props cfg) {:token token :iss :prepared-register})
|
||||
params (merge params claims)
|
||||
params (assoc claims :fullname fullname)
|
||||
|
||||
is-active (or (:is-active params)
|
||||
(not (contains? cf/flags :email-verification))
|
||||
|
||||
;; DEPRECATED: v1.15
|
||||
(contains? cf/flags :insecure-register))
|
||||
(not (contains? cf/flags :email-verification)))
|
||||
|
||||
profile (if-let [profile-id (:profile-id claims)]
|
||||
(profile/retrieve-profile conn profile-id)
|
||||
(->> (assoc params :is-active is-active)
|
||||
(create-profile conn)
|
||||
(create-profile-relations conn)
|
||||
(profile/decode-profile-row)))
|
||||
(profile/get-profile conn profile-id)
|
||||
(let [params (-> params
|
||||
(assoc :is-active is-active)
|
||||
(update :password #(profile/derive-password cfg %)))]
|
||||
(->> (create-profile! conn params)
|
||||
(create-profile-rels! conn))))
|
||||
|
||||
invitation (when-let [token (:invitation-token params)]
|
||||
(tokens/verify (::main/props cfg) {:token token :iss :team-invitation}))]
|
||||
|
||||
@@ -373,9 +347,9 @@
|
||||
(when-let [id (:profile-id claims)]
|
||||
(db/update! conn :profile {:modified-at (dt/now)} {:id id})
|
||||
(audit/submit! cfg
|
||||
{:type "fact"
|
||||
:name "register-profile-retry"
|
||||
:profile-id id}))
|
||||
{::audit/type "fact"
|
||||
::audit/name "register-profile-retry"
|
||||
::audit/profile-id id}))
|
||||
|
||||
(cond
|
||||
;; If invitation token comes in params, this is because the
|
||||
@@ -389,7 +363,7 @@
|
||||
token (tokens/generate (::main/props cfg) claims)
|
||||
resp {:invitation-token token}]
|
||||
(-> resp
|
||||
(rph/with-transform (session/create-fn session (:id profile)))
|
||||
(rph/with-transform (session/create-fn cfg (:id profile)))
|
||||
(rph/with-meta {::audit/replace-props (audit/profile->props profile)
|
||||
::audit/profile-id (:id profile)})))
|
||||
|
||||
@@ -398,7 +372,7 @@
|
||||
;; we need to mark this session as logged.
|
||||
(not= "penpot" (:auth-backend profile))
|
||||
(-> (profile/strip-private-attrs profile)
|
||||
(rph/with-transform (session/create-fn session (:id profile)))
|
||||
(rph/with-transform (session/create-fn cfg (:id profile)))
|
||||
(rph/with-meta {::audit/replace-props (audit/profile->props profile)
|
||||
::audit/profile-id (:id profile)}))
|
||||
|
||||
@@ -406,7 +380,7 @@
|
||||
;; to sign in the user directly, without email verification.
|
||||
(true? is-active)
|
||||
(-> (profile/strip-private-attrs profile)
|
||||
(rph/with-transform (session/create-fn session (:id profile)))
|
||||
(rph/with-transform (session/create-fn cfg (:id profile)))
|
||||
(rph/with-meta {::audit/replace-props (audit/profile->props profile)
|
||||
::audit/profile-id (:id profile)}))
|
||||
|
||||
@@ -418,13 +392,16 @@
|
||||
{::audit/replace-props (audit/profile->props profile)
|
||||
::audit/profile-id (:id profile)})))))
|
||||
|
||||
(s/def ::register-profile
|
||||
(s/keys :req-un [::token ::fullname]))
|
||||
|
||||
(def schema:register-profile
|
||||
[:map {:title "register-profile"}
|
||||
[:token schema:token]
|
||||
[:fullname [::sm/word-string {:max 100}]]])
|
||||
|
||||
(sv/defmethod ::register-profile
|
||||
{::rpc/auth false
|
||||
::climit/queue :auth
|
||||
::doc/added "1.15"}
|
||||
::doc/added "1.15"
|
||||
::sm/params schema:register-profile}
|
||||
[{:keys [::db/pool] :as cfg} params]
|
||||
(db/with-atomic [conn pool]
|
||||
(-> (assoc cfg ::db/conn conn)
|
||||
@@ -448,7 +425,7 @@
|
||||
:exp (dt/in-future {:days 30})})]
|
||||
(eml/send! {::eml/conn conn
|
||||
::eml/factory eml/password-recovery
|
||||
:public-uri (:public-uri cfg)
|
||||
:public-uri (cf/get :public-uri)
|
||||
:to (:email profile)
|
||||
:token (:token profile)
|
||||
:name (:fullname profile)
|
||||
@@ -456,7 +433,7 @@
|
||||
nil))]
|
||||
|
||||
(db/with-atomic [conn pool]
|
||||
(when-let [profile (profile/retrieve-profile-data-by-email conn email)]
|
||||
(when-let [profile (profile/get-profile-by-email conn email)]
|
||||
(when-not (eml/allow-send-emails? conn profile)
|
||||
(ex/raise :type :validation
|
||||
:code :profile-is-muted
|
||||
@@ -476,12 +453,15 @@
|
||||
(create-recovery-token)
|
||||
(send-email-notification conn))))))
|
||||
|
||||
(s/def ::request-profile-recovery
|
||||
(s/keys :req-un [::email]))
|
||||
|
||||
(def schema:request-profile-recovery
|
||||
[:map {:title "request-profile-recovery"}
|
||||
[:email ::sm/email]])
|
||||
|
||||
(sv/defmethod ::request-profile-recovery
|
||||
{::rpc/auth false
|
||||
::doc/added "1.15"}
|
||||
::doc/added "1.15"
|
||||
::sm/params schema:request-profile-recovery}
|
||||
[cfg params]
|
||||
(request-profile-recovery cfg params))
|
||||
|
||||
|
||||
@@ -8,8 +8,10 @@
|
||||
(:refer-clojure :exclude [assert])
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.files.features :as ffeat]
|
||||
[app.common.fressian :as fres]
|
||||
[app.common.logging :as l]
|
||||
[app.common.pages.migrations :as pmg]
|
||||
[app.common.spec :as us]
|
||||
@@ -21,14 +23,13 @@
|
||||
[app.media :as media]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.files :as files]
|
||||
[app.rpc.commands.projects :as projects]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.rpc.helpers :as rph]
|
||||
[app.rpc.queries.projects :as projects]
|
||||
[app.storage :as sto]
|
||||
[app.storage.tmp :as tmp]
|
||||
[app.tasks.file-gc]
|
||||
[app.util.blob :as blob]
|
||||
[app.util.fressian :as fres]
|
||||
[app.util.objects-map :as omap]
|
||||
[app.util.pointer-map :as pmap]
|
||||
[app.util.services :as sv]
|
||||
@@ -45,8 +46,7 @@
|
||||
java.io.DataInputStream
|
||||
java.io.DataOutputStream
|
||||
java.io.InputStream
|
||||
java.io.OutputStream
|
||||
java.lang.AutoCloseable))
|
||||
java.io.OutputStream))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
@@ -109,20 +109,20 @@
|
||||
|
||||
(defn write-byte!
|
||||
[^DataOutputStream output data]
|
||||
(l/trace :fn "write-byte!" :data data :position @*position* ::l/async false)
|
||||
(l/trace :fn "write-byte!" :data data :position @*position* ::l/sync? true)
|
||||
(.writeByte output (byte data))
|
||||
(swap! *position* inc))
|
||||
|
||||
(defn read-byte!
|
||||
[^DataInputStream input]
|
||||
(let [v (.readByte input)]
|
||||
(l/trace :fn "read-byte!" :val v :position @*position* ::l/async false)
|
||||
(l/trace :fn "read-byte!" :val v :position @*position* ::l/sync? true)
|
||||
(swap! *position* inc)
|
||||
v))
|
||||
|
||||
(defn write-long!
|
||||
[^DataOutputStream output data]
|
||||
(l/trace :fn "write-long!" :data data :position @*position* ::l/async false)
|
||||
(l/trace :fn "write-long!" :data data :position @*position* ::l/sync? true)
|
||||
(.writeLong output (long data))
|
||||
(swap! *position* + 8))
|
||||
|
||||
@@ -130,14 +130,14 @@
|
||||
(defn read-long!
|
||||
[^DataInputStream input]
|
||||
(let [v (.readLong input)]
|
||||
(l/trace :fn "read-long!" :val v :position @*position* ::l/async false)
|
||||
(l/trace :fn "read-long!" :val v :position @*position* ::l/sync? true)
|
||||
(swap! *position* + 8)
|
||||
v))
|
||||
|
||||
(defn write-bytes!
|
||||
[^DataOutputStream output ^bytes data]
|
||||
(let [size (alength data)]
|
||||
(l/trace :fn "write-bytes!" :size size :position @*position* ::l/async false)
|
||||
(l/trace :fn "write-bytes!" :size size :position @*position* ::l/sync? true)
|
||||
(.write output data 0 size)
|
||||
(swap! *position* + size)))
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
[^InputStream input ^bytes buff]
|
||||
(let [size (alength buff)
|
||||
readed (.readNBytes input buff 0 size)]
|
||||
(l/trace :fn "read-bytes!" :expected (alength buff) :readed readed :position @*position* ::l/async false)
|
||||
(l/trace :fn "read-bytes!" :expected (alength buff) :readed readed :position @*position* ::l/sync? true)
|
||||
(swap! *position* + readed)
|
||||
readed))
|
||||
|
||||
@@ -153,7 +153,7 @@
|
||||
|
||||
(defn write-uuid!
|
||||
[^DataOutputStream output id]
|
||||
(l/trace :fn "write-uuid!" :position @*position* :WRITTEN? (.size output) ::l/async false)
|
||||
(l/trace :fn "write-uuid!" :position @*position* :WRITTEN? (.size output) ::l/sync? true)
|
||||
|
||||
(doto output
|
||||
(write-byte! (get-mark :uuid))
|
||||
@@ -162,7 +162,7 @@
|
||||
|
||||
(defn read-uuid!
|
||||
[^DataInputStream input]
|
||||
(l/trace :fn "read-uuid!" :position @*position* ::l/async false)
|
||||
(l/trace :fn "read-uuid!" :position @*position* ::l/sync? true)
|
||||
(let [m (read-byte! input)]
|
||||
(assert-mark m :uuid)
|
||||
(let [a (read-long! input)
|
||||
@@ -171,7 +171,7 @@
|
||||
|
||||
(defn write-obj!
|
||||
[^DataOutputStream output data]
|
||||
(l/trace :fn "write-obj!" :position @*position* ::l/async false)
|
||||
(l/trace :fn "write-obj!" :position @*position* ::l/sync? true)
|
||||
(let [^bytes data (fres/encode data)]
|
||||
(doto output
|
||||
(write-byte! (get-mark :obj))
|
||||
@@ -180,7 +180,7 @@
|
||||
|
||||
(defn read-obj!
|
||||
[^DataInputStream input]
|
||||
(l/trace :fn "read-obj!" :position @*position* ::l/async false)
|
||||
(l/trace :fn "read-obj!" :position @*position* ::l/sync? true)
|
||||
(let [m (read-byte! input)]
|
||||
(assert-mark m :obj)
|
||||
(let [size (read-long! input)]
|
||||
@@ -191,14 +191,14 @@
|
||||
|
||||
(defn write-label!
|
||||
[^DataOutputStream output label]
|
||||
(l/trace :fn "write-label!" :label label :position @*position* ::l/async false)
|
||||
(l/trace :fn "write-label!" :label label :position @*position* ::l/sync? true)
|
||||
(doto output
|
||||
(write-byte! (get-mark :label))
|
||||
(write-obj! label)))
|
||||
|
||||
(defn read-label!
|
||||
[^DataInputStream input]
|
||||
(l/trace :fn "read-label!" :position @*position* ::l/async false)
|
||||
(l/trace :fn "read-label!" :position @*position* ::l/sync? true)
|
||||
(let [m (read-byte! input)]
|
||||
(assert-mark m :label)
|
||||
(read-obj! input)))
|
||||
@@ -208,7 +208,7 @@
|
||||
(l/trace :fn "write-header!"
|
||||
:version version
|
||||
:position @*position*
|
||||
::l/async false)
|
||||
::l/sync? true)
|
||||
(let [vers (-> version name (subs 1) parse-long)
|
||||
output (io/data-output-stream output)]
|
||||
(doto output
|
||||
@@ -218,7 +218,7 @@
|
||||
|
||||
(defn read-header!
|
||||
[^InputStream input]
|
||||
(l/trace :fn "read-header!" :position @*position* ::l/async false)
|
||||
(l/trace :fn "read-header!" :position @*position* ::l/sync? true)
|
||||
(let [input (io/data-input-stream input)
|
||||
mark (read-byte! input)
|
||||
mnum (read-long! input)
|
||||
@@ -235,13 +235,13 @@
|
||||
(defn copy-stream!
|
||||
[^OutputStream output ^InputStream input ^long size]
|
||||
(let [written (io/copy! input output :size size)]
|
||||
(l/trace :fn "copy-stream!" :position @*position* :size size :written written ::l/async false)
|
||||
(l/trace :fn "copy-stream!" :position @*position* :size size :written written ::l/sync? true)
|
||||
(swap! *position* + written)
|
||||
written))
|
||||
|
||||
(defn write-stream!
|
||||
[^DataOutputStream output stream size]
|
||||
(l/trace :fn "write-stream!" :position @*position* ::l/async false :size size)
|
||||
(l/trace :fn "write-stream!" :position @*position* ::l/sync? true :size size)
|
||||
(doto output
|
||||
(write-byte! (get-mark :stream))
|
||||
(write-long! size))
|
||||
@@ -250,7 +250,7 @@
|
||||
|
||||
(defn read-stream!
|
||||
[^DataInputStream input]
|
||||
(l/trace :fn "read-stream!" :position @*position* ::l/async false)
|
||||
(l/trace :fn "read-stream!" :position @*position* ::l/sync? true)
|
||||
(let [m (read-byte! input)
|
||||
s (read-long! input)
|
||||
p (tmp/tempfile :prefix "penpot.binfile.")]
|
||||
@@ -264,7 +264,7 @@
|
||||
(if (> s temp-file-threshold)
|
||||
(with-open [^OutputStream output (io/output-stream p)]
|
||||
(let [readed (io/copy! input output :offset 0 :size s)]
|
||||
(l/trace :fn "read-stream*!" :expected s :readed readed :position @*position* ::l/async false)
|
||||
(l/trace :fn "read-stream*!" :expected s :readed readed :position @*position* ::l/sync? true)
|
||||
(swap! *position* + readed)
|
||||
[s p]))
|
||||
[s (io/read-as-bytes input :size s)])))
|
||||
@@ -296,18 +296,18 @@
|
||||
|
||||
(defn- retrieve-file
|
||||
[pool file-id]
|
||||
(with-open [^AutoCloseable conn (db/open pool)]
|
||||
(dm/with-open [conn (db/open pool)]
|
||||
(binding [pmap/*load-fn* (partial files/load-pointer conn file-id)]
|
||||
(some-> (db/get* conn :file {:id file-id})
|
||||
(files/decode-row)
|
||||
(update :data files/process-pointers deref)))))
|
||||
(files/process-pointers deref)))))
|
||||
|
||||
(def ^:private sql:file-media-objects
|
||||
"SELECT * FROM file_media_object WHERE id = ANY(?)")
|
||||
|
||||
(defn- retrieve-file-media
|
||||
[pool {:keys [data id] :as file}]
|
||||
(with-open [^AutoCloseable conn (db/open pool)]
|
||||
(dm/with-open [conn (db/open pool)]
|
||||
(let [ids (app.tasks.file-gc/collect-used-media data)
|
||||
ids (db/create-array conn "uuid" ids)]
|
||||
|
||||
@@ -341,7 +341,7 @@
|
||||
|
||||
(defn- retrieve-libraries
|
||||
[pool ids]
|
||||
(with-open [^AutoCloseable conn (db/open pool)]
|
||||
(dm/with-open [conn (db/open pool)]
|
||||
(let [ids (db/create-array conn "uuid" ids)]
|
||||
(map :id (db/exec! pool [sql:file-libraries ids])))))
|
||||
|
||||
@@ -351,10 +351,9 @@
|
||||
|
||||
(defn- retrieve-library-relations
|
||||
[pool ids]
|
||||
(with-open [^AutoCloseable conn (db/open pool)]
|
||||
(dm/with-open [conn (db/open pool)]
|
||||
(db/exec! conn [sql:file-library-rels (db/create-array conn "uuid" ids)])))
|
||||
|
||||
|
||||
(defn- create-or-update-file
|
||||
[conn params]
|
||||
(let [sql (str "INSERT INTO file (id, project_id, name, revn, is_shared, data, created_at, modified_at) "
|
||||
@@ -438,9 +437,8 @@
|
||||
(s/def ::embed-assets? (s/nilable ::us/boolean))
|
||||
|
||||
(s/def ::write-export-options
|
||||
(s/keys :req-un [::db/pool ::sto/storage]
|
||||
:req [::output ::file-ids]
|
||||
:opt [::include-libraries? ::embed-assets?]))
|
||||
(s/keys :req [::db/pool ::sto/storage ::output ::file-ids]
|
||||
:opt [::include-libraries? ::embed-assets?]))
|
||||
|
||||
(defn write-export!
|
||||
"Do the exportation of a specified file in custom penpot binary
|
||||
@@ -453,6 +451,7 @@
|
||||
`::embed-assets?`: instead of including the libraries, embed in the
|
||||
same file library all assets used from external libraries."
|
||||
[{:keys [::include-libraries? ::embed-assets?] :as options}]
|
||||
|
||||
(us/assert! ::write-export-options options)
|
||||
(us/verify!
|
||||
:expr (not (and include-libraries? embed-assets?))
|
||||
@@ -466,7 +465,7 @@
|
||||
(with-open [output (io/data-output-stream output)]
|
||||
(binding [*state* (volatile! {})]
|
||||
(run! (fn [section]
|
||||
(l/debug :hint "write section" :section section ::l/async false)
|
||||
(l/debug :hint "write section" :section section ::l/sync? true)
|
||||
(write-label! output section)
|
||||
(let [options (-> options
|
||||
(assoc ::output output)
|
||||
@@ -477,7 +476,7 @@
|
||||
[:v1/metadata :v1/files :v1/rels :v1/sobjects])))))
|
||||
|
||||
(defmethod write-section :v1/metadata
|
||||
[{:keys [pool ::output ::file-ids ::include-libraries?]}]
|
||||
[{:keys [::db/pool ::output ::file-ids ::include-libraries?]}]
|
||||
(let [libs (when include-libraries?
|
||||
(retrieve-libraries pool file-ids))
|
||||
files (into file-ids libs)]
|
||||
@@ -485,7 +484,7 @@
|
||||
(vswap! *state* assoc :files files)))
|
||||
|
||||
(defmethod write-section :v1/files
|
||||
[{:keys [pool ::output ::embed-assets?]}]
|
||||
[{:keys [::db/pool ::output ::embed-assets?]}]
|
||||
|
||||
;; Initialize SIDS with empty vector
|
||||
(vswap! *state* assoc :sids [])
|
||||
@@ -500,7 +499,7 @@
|
||||
(l/debug :hint "write penpot file"
|
||||
:id file-id
|
||||
:media (count media)
|
||||
::l/async false)
|
||||
::l/sync? true)
|
||||
|
||||
(doto output
|
||||
(write-obj! file)
|
||||
@@ -509,31 +508,31 @@
|
||||
(vswap! *state* update :sids into storage-object-id-xf media))))
|
||||
|
||||
(defmethod write-section :v1/rels
|
||||
[{:keys [pool ::output ::include-libraries?]}]
|
||||
[{:keys [::db/pool ::output ::include-libraries?]}]
|
||||
(let [rels (when include-libraries?
|
||||
(retrieve-library-relations pool (-> *state* deref :files)))]
|
||||
(l/debug :hint "found rels" :total (count rels) ::l/async false)
|
||||
(l/debug :hint "found rels" :total (count rels) ::l/sync? true)
|
||||
(write-obj! output rels)))
|
||||
|
||||
(defmethod write-section :v1/sobjects
|
||||
[{:keys [storage ::output]}]
|
||||
[{:keys [::sto/storage ::output]}]
|
||||
(let [sids (-> *state* deref :sids)
|
||||
storage (media/configure-assets-storage storage)]
|
||||
(l/debug :hint "found sobjects"
|
||||
:items (count sids)
|
||||
::l/async false)
|
||||
::l/sync? true)
|
||||
|
||||
;; Write all collected storage objects
|
||||
(write-obj! output sids)
|
||||
|
||||
(doseq [id sids]
|
||||
(let [{:keys [size] :as obj} @(sto/get-object storage id)]
|
||||
(l/debug :hint "write sobject" :id id ::l/async false)
|
||||
(let [{:keys [size] :as obj} (sto/get-object storage id)]
|
||||
(l/debug :hint "write sobject" :id id ::l/sync? true)
|
||||
(doto output
|
||||
(write-uuid! id)
|
||||
(write-obj! (meta obj)))
|
||||
|
||||
(with-open [^InputStream stream @(sto/get-object-data storage obj)]
|
||||
(with-open [^InputStream stream (sto/get-object-data storage obj)]
|
||||
(let [written (write-stream! output stream size)]
|
||||
(when (not= written size)
|
||||
(ex/raise :type :validation
|
||||
@@ -557,9 +556,8 @@
|
||||
(s/def ::ignore-index-errors? (s/nilable ::us/boolean))
|
||||
|
||||
(s/def ::read-import-options
|
||||
(s/keys :req-un [::db/pool ::sto/storage]
|
||||
:req [::project-id ::input]
|
||||
:opt [::overwrite? ::migrate? ::ignore-index-errors?]))
|
||||
(s/keys :req [::db/pool ::sto/storage ::project-id ::input]
|
||||
:opt [::overwrite? ::migrate? ::ignore-index-errors?]))
|
||||
|
||||
(defn read-import!
|
||||
"Do the importation of the specified resource in penpot custom binary
|
||||
@@ -582,19 +580,19 @@
|
||||
(read-import (assoc options ::version version ::timestamp timestamp))))
|
||||
|
||||
(defmethod read-import :v1
|
||||
[{:keys [pool ::input] :as options}]
|
||||
[{:keys [::db/pool ::input] :as options}]
|
||||
(with-open [input (zstd-input-stream input)]
|
||||
(with-open [input (io/data-input-stream input)]
|
||||
(db/with-atomic [conn pool]
|
||||
(db/exec-one! conn ["SET CONSTRAINTS ALL DEFERRED;"])
|
||||
(binding [*state* (volatile! {:media [] :index {}})]
|
||||
(run! (fn [section]
|
||||
(l/debug :hint "reading section" :section section ::l/async false)
|
||||
(l/debug :hint "reading section" :section section ::l/sync? true)
|
||||
(assert-read-label! input section)
|
||||
(let [options (-> options
|
||||
(assoc ::section section)
|
||||
(assoc ::input input)
|
||||
(assoc :conn conn))]
|
||||
(assoc ::db/conn conn))]
|
||||
(binding [*options* options]
|
||||
(read-section options))))
|
||||
[:v1/metadata :v1/files :v1/rels :v1/sobjects])
|
||||
@@ -607,7 +605,7 @@
|
||||
(defmethod read-section :v1/metadata
|
||||
[{:keys [::input]}]
|
||||
(let [{:keys [version files]} (read-obj! input)]
|
||||
(l/debug :hint "metadata readed" :version (:full version) :files files ::l/async false)
|
||||
(l/debug :hint "metadata readed" :version (:full version) :files files ::l/sync? true)
|
||||
(vswap! *state* update :index update-index files)
|
||||
(vswap! *state* assoc :version version :files files)))
|
||||
|
||||
@@ -618,16 +616,16 @@
|
||||
(-> data
|
||||
(update :pages-index update-vals #(update % :objects omap-wrap))
|
||||
(update :pages-index update-vals pmap-wrap)
|
||||
(update :components update-vals #(update % :objects omap-wrap))
|
||||
(update :components update-vals #(d/update-when % :objects omap-wrap))
|
||||
(update :components pmap-wrap))))
|
||||
|
||||
(defmethod read-section :v1/files
|
||||
[{:keys [conn ::input ::migrate? ::project-id ::timestamp ::overwrite?]}]
|
||||
[{:keys [::db/conn ::input ::migrate? ::project-id ::timestamp ::overwrite?]}]
|
||||
(doseq [expected-file-id (-> *state* deref :files)]
|
||||
(let [file (read-obj! input)
|
||||
media' (read-obj! input)
|
||||
file-id (:id file)
|
||||
features files/default-features]
|
||||
features (files/get-default-features)]
|
||||
|
||||
(when (not= file-id expected-file-id)
|
||||
(ex/raise :type :validation
|
||||
@@ -635,14 +633,14 @@
|
||||
:hint "the penpot file seems corrupt, found unexpected uuid (file-id)"))
|
||||
|
||||
;; Update index using with media
|
||||
(l/debug :hint "update index with media" ::l/async false)
|
||||
(l/debug :hint "update index with media" ::l/sync? true)
|
||||
(vswap! *state* update :index update-index (map :id media'))
|
||||
|
||||
;; Store file media for later insertion
|
||||
(l/debug :hint "update media references" ::l/async false)
|
||||
(l/debug :hint "update media references" ::l/sync? true)
|
||||
(vswap! *state* update :media into (map #(update % :id lookup-index)) media')
|
||||
|
||||
(l/debug :hint "processing file" :file-id file-id ::features features ::l/async false)
|
||||
(l/debug :hint "processing file" :file-id file-id ::features features ::l/sync? true)
|
||||
|
||||
(binding [ffeat/*current* features
|
||||
ffeat/*wrap-with-objects-map-fn* (if (features "storage/objects-map") omap/wrap identity)
|
||||
@@ -668,7 +666,7 @@
|
||||
:created-at timestamp
|
||||
:modified-at timestamp}]
|
||||
|
||||
(l/debug :hint "create file" :id file-id' ::l/async false)
|
||||
(l/debug :hint "create file" :id file-id' ::l/sync? true)
|
||||
|
||||
(if overwrite?
|
||||
(create-or-update-file conn params)
|
||||
@@ -680,7 +678,7 @@
|
||||
(db/delete! conn :file-thumbnail {:file-id file-id'})))))))
|
||||
|
||||
(defmethod read-section :v1/rels
|
||||
[{:keys [conn ::input ::timestamp]}]
|
||||
[{:keys [::db/conn ::input ::timestamp]}]
|
||||
(let [rels (read-obj! input)]
|
||||
;; Insert all file relations
|
||||
(doseq [rel rels]
|
||||
@@ -691,11 +689,11 @@
|
||||
(l/debug :hint "create file library link"
|
||||
:file-id (:file-id rel)
|
||||
:lib-id (:library-file-id rel)
|
||||
::l/async false)
|
||||
::l/sync? true)
|
||||
(db/insert! conn :file-library-rel rel)))))
|
||||
|
||||
(defmethod read-section :v1/sobjects
|
||||
[{:keys [storage conn ::input ::overwrite?]}]
|
||||
[{:keys [::sto/storage ::db/conn ::input ::overwrite?]}]
|
||||
(let [storage (media/configure-assets-storage storage)
|
||||
ids (read-obj! input)]
|
||||
|
||||
@@ -708,7 +706,7 @@
|
||||
:code :inconsistent-penpot-file
|
||||
:hint "the penpot file seems corrupt, found unexpected uuid (storage-object-id)"))
|
||||
|
||||
(l/debug :hint "readed storage object" :id id ::l/async false)
|
||||
(l/debug :hint "readed storage object" :id id ::l/sync? true)
|
||||
|
||||
(let [[size resource] (read-stream! input)
|
||||
hash (sto/calculate-hash resource)
|
||||
@@ -720,20 +718,20 @@
|
||||
(assoc ::sto/touched-at (dt/now))
|
||||
(assoc :bucket "file-media-object"))
|
||||
|
||||
sobject @(sto/put-object! storage params)]
|
||||
sobject (sto/put-object! storage params)]
|
||||
|
||||
(l/debug :hint "persisted storage object" :id id :new-id (:id sobject) ::l/async false)
|
||||
(l/debug :hint "persisted storage object" :id id :new-id (:id sobject) ::l/sync? true)
|
||||
(vswap! *state* update :index assoc id (:id sobject)))))
|
||||
|
||||
(doseq [item (:media @*state*)]
|
||||
(l/debug :hint "inserting file media object"
|
||||
:id (:id item)
|
||||
:file-id (:file-id item)
|
||||
::l/async false)
|
||||
::l/sync? true)
|
||||
|
||||
(let [file-id (lookup-index (:file-id item))]
|
||||
(if (= file-id (:file-id item))
|
||||
(l/warn :hint "ignoring file media object" :file-id (:file-id item) ::l/async false)
|
||||
(l/warn :hint "ignoring file media object" :file-id (:file-id item) ::l/sync? true)
|
||||
(db/insert! conn :file-media-object
|
||||
(-> item
|
||||
(assoc :file-id file-id)
|
||||
@@ -744,7 +742,7 @@
|
||||
(defn- lookup-index
|
||||
[id]
|
||||
(let [val (get-in @*state* [:index id])]
|
||||
(l/trace :fn "lookup-index" :id id :val val ::l/async false)
|
||||
(l/trace :fn "lookup-index" :id id :val val ::l/sync? true)
|
||||
(when (and (not (::ignore-index-errors? *options*)) (not val))
|
||||
(ex/raise :type :validation
|
||||
:code :incomplete-index
|
||||
@@ -757,7 +755,7 @@
|
||||
index index]
|
||||
(if-let [id (first items)]
|
||||
(let [new-id (if (::overwrite? *options*) id (uuid/next))]
|
||||
(l/trace :fn "update-index" :id id :new-id new-id ::l/async false)
|
||||
(l/trace :fn "update-index" :id id :new-id new-id ::l/sync? true)
|
||||
(recur (rest items)
|
||||
(assoc index id new-id)))
|
||||
index)))
|
||||
@@ -805,7 +803,7 @@
|
||||
(try
|
||||
(process-map-form form)
|
||||
(catch Throwable cause
|
||||
(l/warn :hint "failed form" :form (pr-str form) ::l/async false)
|
||||
(l/warn :hint "failed form" :form (pr-str form) ::l/sync? true)
|
||||
(throw cause)))
|
||||
form))
|
||||
data)))
|
||||
@@ -836,7 +834,7 @@
|
||||
cs (volatile! nil)]
|
||||
(try
|
||||
(l/info :hint "start exportation" :export-id id)
|
||||
(with-open [^AutoCloseable output (io/output-stream output)]
|
||||
(dm/with-open [output (io/output-stream output)]
|
||||
(binding [*position* (atom 0)]
|
||||
(write-export! (assoc cfg ::output output))))
|
||||
|
||||
@@ -859,7 +857,7 @@
|
||||
(defn export-to-tmpfile!
|
||||
[cfg]
|
||||
(let [path (tmp/tempfile :prefix "penpot.export.")]
|
||||
(with-open [^AutoCloseable output (io/output-stream path)]
|
||||
(dm/with-open [output (io/output-stream path)]
|
||||
(export! cfg output)
|
||||
path)))
|
||||
|
||||
@@ -871,7 +869,7 @@
|
||||
(l/info :hint "import: started" :import-id id)
|
||||
(try
|
||||
(binding [*position* (atom 0)]
|
||||
(with-open [^AutoCloseable input (io/input-stream input)]
|
||||
(dm/with-open [input (io/input-stream input)]
|
||||
(read-import! (assoc cfg ::input input))))
|
||||
|
||||
(catch Throwable cause
|
||||
@@ -893,13 +891,14 @@
|
||||
(s/def ::embed-assets? ::us/boolean)
|
||||
|
||||
(s/def ::export-binfile
|
||||
(s/keys :req [::rpc/profile-id] :req-un [::file-id ::include-libraries? ::embed-assets?]))
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::file-id ::include-libraries? ::embed-assets?]))
|
||||
|
||||
(sv/defmethod ::export-binfile
|
||||
"Export a penpot file in a binary format."
|
||||
{::doc/added "1.15"
|
||||
::webhooks/event? true}
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id file-id include-libraries? embed-assets?] :as params}]
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id include-libraries? embed-assets?] :as params}]
|
||||
(files/check-read-permissions! pool profile-id file-id)
|
||||
(let [body (reify yrs/StreamableResponseBody
|
||||
(-write-body-to-stream [_ _ output-stream]
|
||||
@@ -910,22 +909,30 @@
|
||||
(export! output-stream))))]
|
||||
|
||||
(fn [_]
|
||||
(yrs/response 200 body {"content-type" "application/octet-stream"}))))
|
||||
{::yrs/status 200
|
||||
::yrs/body body
|
||||
::yrs/headers {"content-type" "application/octet-stream"}})))
|
||||
|
||||
(s/def ::file ::media/upload)
|
||||
(s/def ::import-binfile
|
||||
(s/keys :req [::rpc/profile-id] :req-un [::project-id ::file]))
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::project-id ::file]))
|
||||
|
||||
(sv/defmethod ::import-binfile
|
||||
"Import a penpot file in a binary format."
|
||||
{::doc/added "1.15"
|
||||
::webhooks/event? true}
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id project-id file] :as params}]
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id file] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(projects/check-read-permissions! conn profile-id project-id)
|
||||
(let [ids (import! (assoc cfg
|
||||
::input (:path file)
|
||||
::project-id project-id
|
||||
::ignore-index-errors? true))]
|
||||
|
||||
(db/update! conn :project
|
||||
{:modified-at (dt/now)}
|
||||
{:id project-id})
|
||||
|
||||
(rph/with-meta ids
|
||||
{::audit/props {:file nil :file-ids ids}}))))
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
[app.rpc.commands.teams :as teams]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.rpc.quotes :as quotes]
|
||||
[app.rpc.retry :as rtry]
|
||||
[app.util.pointer-map :as pmap]
|
||||
[app.util.retry :as rtry]
|
||||
[app.util.services :as sv]
|
||||
[app.util.time :as dt]
|
||||
[clojure.spec.alpha :as s]))
|
||||
@@ -54,8 +54,8 @@
|
||||
:hint "file not found"))))
|
||||
|
||||
(defn- get-comment-thread
|
||||
[conn thread-id & {:keys [for-update?]}]
|
||||
(-> (db/get-by-id conn :comment-thread thread-id {:for-update for-update?})
|
||||
[conn thread-id & {:as opts}]
|
||||
(-> (db/get-by-id conn :comment-thread thread-id opts)
|
||||
(decode-row)))
|
||||
|
||||
(defn- get-comment
|
||||
@@ -100,8 +100,8 @@
|
||||
|
||||
(sv/defmethod ::get-comment-threads
|
||||
{::doc/added "1.15"}
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id file-id share-id] :as params}]
|
||||
(with-open [conn (db/open pool)]
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id share-id] :as params}]
|
||||
(dm/with-open [conn (db/open pool)]
|
||||
(files/check-comment-permissions! conn profile-id file-id share-id)
|
||||
(get-comment-threads conn profile-id file-id)))
|
||||
|
||||
@@ -143,8 +143,8 @@
|
||||
|
||||
(sv/defmethod ::get-unread-comment-threads
|
||||
{::doc/added "1.15"}
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id team-id] :as params}]
|
||||
(with-open [conn (db/open pool)]
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id] :as params}]
|
||||
(dm/with-open [conn (db/open pool)]
|
||||
(teams/check-read-permissions! conn profile-id team-id)
|
||||
(get-unread-comment-threads conn profile-id team-id)))
|
||||
|
||||
@@ -190,8 +190,8 @@
|
||||
|
||||
(sv/defmethod ::get-comment-thread
|
||||
{::doc/added "1.15"}
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id file-id id share-id] :as params}]
|
||||
(with-open [conn (db/open pool)]
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id id share-id] :as params}]
|
||||
(dm/with-open [conn (db/open pool)]
|
||||
(files/check-comment-permissions! conn profile-id file-id share-id)
|
||||
(let [sql (str "with threads as (" sql:comment-threads ")"
|
||||
"select * from threads where id = ?")]
|
||||
@@ -210,8 +210,8 @@
|
||||
|
||||
(sv/defmethod ::get-comments
|
||||
{::doc/added "1.15"}
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id thread-id share-id] :as params}]
|
||||
(with-open [conn (db/open pool)]
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id thread-id share-id] :as params}]
|
||||
(dm/with-open [conn (db/open pool)]
|
||||
(let [{:keys [file-id] :as thread} (get-comment-thread conn thread-id)]
|
||||
(files/check-comment-permissions! conn profile-id file-id share-id)
|
||||
(get-comments conn thread-id))))
|
||||
@@ -262,8 +262,8 @@
|
||||
participants on comment threads of the file."
|
||||
{::doc/added "1.15"
|
||||
::doc/changes ["1.15" "Imported from queries and renamed."]}
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id file-id share-id]}]
|
||||
(with-open [conn (db/open pool)]
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id share-id]}]
|
||||
(dm/with-open [conn (db/open pool)]
|
||||
(files/check-comment-permissions! conn profile-id file-id share-id)
|
||||
(get-file-comments-users conn file-id profile-id)))
|
||||
|
||||
@@ -309,7 +309,8 @@
|
||||
|
||||
(rtry/with-retry {::rtry/when rtry/conflict-exception?
|
||||
::rtry/max-retries 3
|
||||
::rtry/label "create-comment-thread"}
|
||||
::rtry/label "create-comment-thread"
|
||||
::db/conn conn}
|
||||
(create-comment-thread conn
|
||||
{:created-at request-at
|
||||
:profile-id profile-id
|
||||
@@ -372,9 +373,9 @@
|
||||
|
||||
(sv/defmethod ::update-comment-thread-status
|
||||
{::doc/added "1.15"}
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id id share-id] :as params}]
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id id share-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [{:keys [file-id] :as thread} (get-comment-thread conn id :for-update? true)]
|
||||
(let [{:keys [file-id] :as thread} (get-comment-thread conn id ::db/for-update? true)]
|
||||
(files/check-comment-permissions! conn profile-id file-id share-id)
|
||||
(upsert-comment-thread-status! conn profile-id id))))
|
||||
|
||||
@@ -389,9 +390,9 @@
|
||||
|
||||
(sv/defmethod ::update-comment-thread
|
||||
{::doc/added "1.15"}
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id id is-resolved share-id] :as params}]
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id id is-resolved share-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [{:keys [file-id] :as thread} (get-comment-thread conn id :for-update? true)]
|
||||
(let [{:keys [file-id] :as thread} (get-comment-thread conn id ::db/for-update? true)]
|
||||
(files/check-comment-permissions! conn profile-id file-id share-id)
|
||||
(db/update! conn :comment-thread
|
||||
{:is-resolved is-resolved}
|
||||
@@ -412,9 +413,9 @@
|
||||
(sv/defmethod ::create-comment
|
||||
{::doc/added "1.15"
|
||||
::webhooks/event? true}
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id ::rpc/request-at thread-id share-id content] :as params}]
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id ::rpc/request-at thread-id share-id content] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [{:keys [file-id page-id] :as thread} (get-comment-thread conn thread-id :for-update? true)
|
||||
(let [{:keys [file-id page-id] :as thread} (get-comment-thread conn thread-id ::db/for-update? true)
|
||||
{:keys [team-id project-id page-name] :as file} (get-file conn file-id page-id)]
|
||||
|
||||
(files/check-comment-permissions! conn profile-id (:id file) share-id)
|
||||
@@ -465,10 +466,10 @@
|
||||
|
||||
(sv/defmethod ::update-comment
|
||||
{::doc/added "1.15"}
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id ::rpc/request-at id share-id content] :as params}]
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id ::rpc/request-at id share-id content] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [{:keys [thread-id] :as comment} (get-comment conn id :for-update? true)
|
||||
{:keys [file-id page-id owner-id] :as thread} (get-comment-thread conn thread-id :for-update? true)]
|
||||
(let [{:keys [thread-id owner-id] :as comment} (get-comment conn id ::db/for-update? true)
|
||||
{:keys [file-id page-id] :as thread} (get-comment-thread conn thread-id ::db/for-update? true)]
|
||||
|
||||
(files/check-comment-permissions! conn profile-id file-id share-id)
|
||||
|
||||
@@ -498,9 +499,9 @@
|
||||
|
||||
(sv/defmethod ::delete-comment-thread
|
||||
{::doc/added "1.15"}
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id id share-id] :as params}]
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id id share-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [{:keys [owner-id file-id] :as thread} (get-comment-thread conn id :for-update? true)]
|
||||
(let [{:keys [owner-id file-id] :as thread} (get-comment-thread conn id ::db/for-update? true)]
|
||||
(files/check-comment-permissions! conn profile-id file-id share-id)
|
||||
(when-not (= owner-id profile-id)
|
||||
(ex/raise :type :validation
|
||||
@@ -518,9 +519,9 @@
|
||||
|
||||
(sv/defmethod ::delete-comment
|
||||
{::doc/added "1.15"}
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id id share-id] :as params}]
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id id share-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [{:keys [owner-id thread-id] :as comment} (get-comment conn id :for-update? true)
|
||||
(let [{:keys [owner-id thread-id] :as comment} (get-comment conn id ::db/for-update? true)
|
||||
{:keys [file-id] :as thread} (get-comment-thread conn thread-id)]
|
||||
(files/check-comment-permissions! conn profile-id file-id share-id)
|
||||
(when-not (= owner-id profile-id)
|
||||
@@ -538,9 +539,9 @@
|
||||
|
||||
(sv/defmethod ::update-comment-thread-position
|
||||
{::doc/added "1.15"}
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id id position frame-id share-id] :as params}]
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id id position frame-id share-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [{:keys [file-id] :as thread} (get-comment-thread conn id :for-update? true)]
|
||||
(let [{:keys [file-id] :as thread} (get-comment-thread conn id ::db/for-update? true)]
|
||||
(files/check-comment-permissions! conn profile-id file-id share-id)
|
||||
(db/update! conn :comment-thread
|
||||
{:modified-at (::rpc/request-at params)
|
||||
@@ -558,9 +559,9 @@
|
||||
|
||||
(sv/defmethod ::update-comment-thread-frame
|
||||
{::doc/added "1.15"}
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id id frame-id share-id] :as params}]
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id id frame-id share-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [{:keys [file-id] :as thread} (get-comment-thread conn id :for-update? true)]
|
||||
(let [{:keys [file-id] :as thread} (get-comment-thread conn id ::db/for-update? true)]
|
||||
(files/check-comment-permissions! conn profile-id file-id share-id)
|
||||
(db/update! conn :comment-thread
|
||||
{:modified-at (::rpc/request-at params)
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
"A demo specific mutations."
|
||||
(:require
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.loggers.audit :as audit]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.auth :as cmd.auth]
|
||||
[app.rpc.commands.auth :as auth]
|
||||
[app.rpc.commands.profile :as profile]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.util.services :as sv]
|
||||
[app.util.time :as dt]
|
||||
@@ -30,32 +30,31 @@
|
||||
{::rpc/auth false
|
||||
::doc/added "1.15"
|
||||
::doc/changes ["1.15" "This method is migrated from mutations to commands."]}
|
||||
[{:keys [pool] :as cfg} _]
|
||||
(let [id (uuid/next)
|
||||
sem (System/currentTimeMillis)
|
||||
[{:keys [::db/pool] :as cfg} _]
|
||||
|
||||
(when-not (contains? cf/flags :demo-users)
|
||||
(ex/raise :type :validation
|
||||
:code :demo-users-not-allowed
|
||||
:hint "Demo users are disabled by config."))
|
||||
|
||||
(let [sem (System/currentTimeMillis)
|
||||
email (str "demo-" sem ".demo@example.com")
|
||||
fullname (str "Demo User " sem)
|
||||
|
||||
password (-> (bn/random-bytes 16)
|
||||
(bc/bytes->b64u)
|
||||
(bc/bytes->str))
|
||||
params {:id id
|
||||
:email email
|
||||
|
||||
params {:email email
|
||||
:fullname fullname
|
||||
:is-active true
|
||||
:deleted-at (dt/in-future cf/deletion-delay)
|
||||
:password password
|
||||
:props {}
|
||||
}]
|
||||
|
||||
(when-not (contains? cf/flags :demo-users)
|
||||
(ex/raise :type :validation
|
||||
:code :demo-users-not-allowed
|
||||
:hint "Demo users are disabled by config."))
|
||||
:password (profile/derive-password cfg password)
|
||||
:props {}}]
|
||||
|
||||
(db/with-atomic [conn pool]
|
||||
(->> (cmd.auth/create-profile conn params)
|
||||
(cmd.auth/create-profile-relations conn))
|
||||
|
||||
(with-meta {:email email
|
||||
:password password}
|
||||
{::audit/profile-id id}))))
|
||||
(let [profile (->> (auth/create-profile! conn params)
|
||||
(auth/create-profile-rels! conn))]
|
||||
(with-meta {:email email
|
||||
:password password}
|
||||
{::audit/profile-id (:id profile)})))))
|
||||
|
||||
56
backend/src/app/rpc/commands/feedback.clj
Normal file
56
backend/src/app/rpc/commands/feedback.clj
Normal file
@@ -0,0 +1,56 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC
|
||||
|
||||
(ns app.rpc.commands.feedback
|
||||
"A general purpose feedback module."
|
||||
(:require
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.spec :as us]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.email :as eml]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.profile :as profile]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.util.services :as sv]
|
||||
[clojure.spec.alpha :as s]))
|
||||
|
||||
(declare ^:private send-feedback!)
|
||||
|
||||
(s/def ::content ::us/string)
|
||||
(s/def ::from ::us/email)
|
||||
(s/def ::subject ::us/string)
|
||||
|
||||
(s/def ::send-user-feedback
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::subject
|
||||
::content]))
|
||||
|
||||
(sv/defmethod ::send-user-feedback
|
||||
{::doc/added "1.18"}
|
||||
[{:keys [::db/pool]} {:keys [::rpc/profile-id] :as params}]
|
||||
(when-not (contains? cf/flags :user-feedback)
|
||||
(ex/raise :type :restriction
|
||||
:code :feedback-disabled
|
||||
:hint "feedback not enabled"))
|
||||
|
||||
(let [profile (profile/get-profile pool profile-id)]
|
||||
(send-feedback! pool profile params)
|
||||
nil))
|
||||
|
||||
(defn- send-feedback!
|
||||
[pool profile params]
|
||||
(let [dest (cf/get :feedback-destination)]
|
||||
(eml/send! {::eml/conn pool
|
||||
::eml/factory eml/feedback
|
||||
:from dest
|
||||
:to dest
|
||||
:profile profile
|
||||
:reply-to (:email profile)
|
||||
:email (:email profile)
|
||||
:subject (:subject params)
|
||||
:content (:content params)})
|
||||
nil))
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC
|
||||
|
||||
(ns app.rpc.commands.files.create
|
||||
(ns app.rpc.commands.files-create
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.files.features :as ffeat]
|
||||
@@ -15,14 +15,15 @@
|
||||
[app.loggers.webhooks :as-alias webhooks]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.files :as files]
|
||||
[app.rpc.commands.projects :as projects]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.rpc.permissions :as perms]
|
||||
[app.rpc.queries.projects :as proj]
|
||||
[app.rpc.quotes :as quotes]
|
||||
[app.util.blob :as blob]
|
||||
[app.util.objects-map :as omap]
|
||||
[app.util.pointer-map :as pmap]
|
||||
[app.util.services :as sv]
|
||||
[app.util.time :as dt]
|
||||
[clojure.spec.alpha :as s]))
|
||||
|
||||
(defn create-file-role!
|
||||
@@ -33,22 +34,25 @@
|
||||
(db/insert! conn :file-profile-rel))))
|
||||
|
||||
(defn create-file
|
||||
[conn {:keys [id name project-id is-shared data revn
|
||||
[conn {:keys [id name project-id is-shared revn
|
||||
modified-at deleted-at create-page
|
||||
ignore-sync-until features]
|
||||
:or {is-shared false revn 0 create-page true}
|
||||
:as params}]
|
||||
(let [id (or id (:id data) (uuid/next))
|
||||
features (-> (into files/default-features features)
|
||||
(files/check-features-compatibility!))
|
||||
|
||||
data (or data
|
||||
(binding [ffeat/*current* features
|
||||
ffeat/*wrap-with-objects-map-fn* (if (features "storate/objects-map") omap/wrap identity)
|
||||
ffeat/*wrap-with-pointer-map-fn* (if (features "storage/pointer-map") pmap/wrap identity)]
|
||||
(if create-page
|
||||
(ctf/make-file-data id)
|
||||
(ctf/make-file-data id nil))))
|
||||
(let [id (or id (uuid/next))
|
||||
features (->> features
|
||||
(into (files/get-default-features))
|
||||
(files/check-features-compatibility!))
|
||||
|
||||
pointers (atom {})
|
||||
data (binding [pmap/*tracked* pointers
|
||||
ffeat/*current* features
|
||||
ffeat/*wrap-with-objects-map-fn* (if (features "storate/objects-map") omap/wrap identity)
|
||||
ffeat/*wrap-with-pointer-map-fn* (if (features "storage/pointer-map") pmap/wrap identity)]
|
||||
(if create-page
|
||||
(ctf/make-file-data id)
|
||||
(ctf/make-file-data id nil)))
|
||||
|
||||
features (db/create-array conn "text" features)
|
||||
file (db/insert! conn :file
|
||||
@@ -64,9 +68,16 @@
|
||||
:modified-at modified-at
|
||||
:deleted-at deleted-at}))]
|
||||
|
||||
(binding [pmap/*tracked* pointers]
|
||||
(files/persist-pointers! conn id))
|
||||
|
||||
(->> (assoc params :file-id id :role :owner)
|
||||
(create-file-role! conn))
|
||||
|
||||
(db/update! conn :project
|
||||
{:modified-at (dt/now)}
|
||||
{:id project-id})
|
||||
|
||||
(files/decode-row file)))
|
||||
|
||||
(s/def ::create-file
|
||||
@@ -79,10 +90,11 @@
|
||||
|
||||
(sv/defmethod ::create-file
|
||||
{::doc/added "1.17"
|
||||
::doc/module :files
|
||||
::webhooks/event? true}
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id project-id] :as params}]
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(proj/check-edition-permissions! conn profile-id project-id)
|
||||
(projects/check-edition-permissions! conn profile-id project-id)
|
||||
(let [team-id (files/get-team-id conn project-id)
|
||||
params (assoc params :profile-id profile-id)]
|
||||
|
||||
@@ -4,42 +4,44 @@
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC
|
||||
|
||||
(ns app.rpc.mutations.share-link
|
||||
(ns app.rpc.commands.files-share
|
||||
"Share link related rpc mutation methods."
|
||||
(:require
|
||||
[app.common.spec :as us]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.db :as db]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.files :as files]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[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 ::who-comment ::us/string)
|
||||
(s/def ::who-inspect ::us/string)
|
||||
(s/def ::pages (s/every ::us/uuid :kind set?))
|
||||
|
||||
;; --- Mutation: Create Share Link
|
||||
;; --- MUTATION: Create Share Link
|
||||
|
||||
(declare create-share-link)
|
||||
|
||||
(s/def ::create-share-link
|
||||
(s/keys :req-un [::profile-id ::file-id ::who-comment ::who-inspect ::pages]))
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::file-id ::who-comment ::who-inspect ::pages]))
|
||||
|
||||
(sv/defmethod ::create-share-link
|
||||
"Creates a share-link object.
|
||||
|
||||
Share links are resources that allows external users access to specific
|
||||
pages of a file with specific permissions (who-comment and who-inspect)."
|
||||
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id file-id] :as params}]
|
||||
{::doc/added "1.18"
|
||||
::doc/module :files}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(files/check-edition-permissions! conn profile-id file-id)
|
||||
(create-share-link conn params)))
|
||||
(create-share-link conn (assoc params :profile-id profile-id))))
|
||||
|
||||
(defn create-share-link
|
||||
[conn {:keys [profile-id file-id pages who-comment who-inspect]}]
|
||||
@@ -51,18 +53,19 @@
|
||||
:who-inspect who-inspect
|
||||
:pages pages
|
||||
:owner-id profile-id})]
|
||||
(-> slink
|
||||
(update :pages db/decode-pgarray #{}))))
|
||||
|
||||
;; --- Mutation: Delete Share Link
|
||||
(update slink :pages db/decode-pgarray #{})))
|
||||
|
||||
(declare delete-share-link)
|
||||
;; --- MUTATION: Delete Share Link
|
||||
|
||||
(s/def ::delete-share-link
|
||||
(s/keys :req-un [::profile-id ::id]))
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::us/id]))
|
||||
|
||||
(sv/defmethod ::delete-share-link
|
||||
[{:keys [pool] :as cfg} {:keys [profile-id id] :as params}]
|
||||
{::doc/added "1.18"
|
||||
::doc/module ::files}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/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))
|
||||
@@ -4,7 +4,7 @@
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC
|
||||
|
||||
(ns app.rpc.commands.files.temp
|
||||
(ns app.rpc.commands.files-temp
|
||||
(:require
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.pages :as cp]
|
||||
@@ -13,10 +13,10 @@
|
||||
[app.db :as db]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.files :as files]
|
||||
[app.rpc.commands.files.create :as files.create]
|
||||
[app.rpc.commands.files.update :as files.update]
|
||||
[app.rpc.commands.files-create :refer [create-file]]
|
||||
[app.rpc.commands.files-update :as-alias files.update]
|
||||
[app.rpc.commands.projects :as projects]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.rpc.queries.projects :as proj]
|
||||
[app.util.blob :as blob]
|
||||
[app.util.services :as sv]
|
||||
[app.util.time :as dt]
|
||||
@@ -36,16 +36,17 @@
|
||||
::create-page]))
|
||||
|
||||
(sv/defmethod ::create-temp-file
|
||||
{::doc/added "1.17"}
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id project-id] :as params}]
|
||||
{::doc/added "1.17"
|
||||
::doc/module :files}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(proj/check-edition-permissions! conn profile-id project-id)
|
||||
(files.create/create-file conn (assoc params :profile-id profile-id :deleted-at (dt/in-future {:days 1})))))
|
||||
(projects/check-edition-permissions! conn profile-id project-id)
|
||||
(create-file conn (assoc params :profile-id profile-id :deleted-at (dt/in-future {:days 1})))))
|
||||
|
||||
;; --- MUTATION COMMAND: update-temp-file
|
||||
|
||||
(defn update-temp-file
|
||||
[conn {:keys [::rpc/profile-id session-id id revn changes] :as params}]
|
||||
[conn {:keys [profile-id session-id id revn changes] :as params}]
|
||||
(db/insert! conn :file-change
|
||||
{:id (uuid/next)
|
||||
:session-id session-id
|
||||
@@ -57,16 +58,18 @@
|
||||
:changes (blob/encode changes)}))
|
||||
|
||||
(s/def ::update-temp-file
|
||||
(s/keys :req-un [::files.update/changes
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::files.update/changes
|
||||
::files.update/revn
|
||||
::files.update/session-id
|
||||
::files/id]))
|
||||
|
||||
(sv/defmethod ::update-temp-file
|
||||
{::doc/added "1.17"}
|
||||
[{:keys [pool] :as cfg} params]
|
||||
{::doc/added "1.17"
|
||||
::doc/module :files}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(update-temp-file conn params)
|
||||
(update-temp-file conn (assoc params :profile-id profile-id))
|
||||
nil))
|
||||
|
||||
;; --- MUTATION COMMAND: persist-temp-file
|
||||
@@ -83,16 +86,16 @@
|
||||
(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})))
|
||||
|
||||
(let [data
|
||||
(->> revs
|
||||
(mapcat #(->> % :changes blob/decode))
|
||||
(cp/process-changes (blob/decode (:data file))))]
|
||||
(db/update! conn :file
|
||||
{:deleted-at nil
|
||||
:revn revn
|
||||
:data (blob/encode data)}
|
||||
{:id id}))
|
||||
nil))
|
||||
|
||||
(s/def ::persist-temp-file
|
||||
@@ -100,8 +103,9 @@
|
||||
:req-un [::files/id]))
|
||||
|
||||
(sv/defmethod ::persist-temp-file
|
||||
{::doc/added "1.17"}
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id id] :as params}]
|
||||
{::doc/added "1.17"
|
||||
::doc/module :files}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(files/check-edition-permissions! conn profile-id id)
|
||||
(persist-temp-file conn params)))
|
||||
449
backend/src/app/rpc/commands/files_thumbnails.clj
Normal file
449
backend/src/app/rpc/commands/files_thumbnails.clj
Normal file
@@ -0,0 +1,449 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC
|
||||
|
||||
(ns app.rpc.commands.files-thumbnails
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.geom.shapes :as gsh]
|
||||
[app.common.pages.helpers :as cph]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.spec :as us]
|
||||
[app.common.types.shape-tree :as ctt]
|
||||
[app.db :as db]
|
||||
[app.db.sql :as sql]
|
||||
[app.loggers.audit :as-alias audit]
|
||||
[app.loggers.webhooks :as-alias webhooks]
|
||||
[app.media :as media]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.files :as files]
|
||||
[app.rpc.cond :as-alias cond]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.rpc.helpers :as rph]
|
||||
[app.storage :as sto]
|
||||
[app.util.pointer-map :as pmap]
|
||||
[app.util.services :as sv]
|
||||
[app.util.time :as dt]
|
||||
[clojure.spec.alpha :as s]
|
||||
[cuerdas.core :as str]))
|
||||
|
||||
;; --- FEATURES
|
||||
|
||||
(def long-cache-duration
|
||||
(dt/duration {:days 7}))
|
||||
|
||||
;; --- COMMAND QUERY: get-file-object-thumbnails
|
||||
|
||||
(defn- get-object-thumbnails
|
||||
([conn file-id]
|
||||
(let [sql (str/concat
|
||||
"select object_id, data, media_id "
|
||||
" from file_object_thumbnail"
|
||||
" where file_id=?")
|
||||
res (db/exec! conn [sql file-id])]
|
||||
(->> res
|
||||
(d/index-by :object-id (fn [row]
|
||||
(or (some-> row :media-id files/resolve-public-uri)
|
||||
(:data row))))
|
||||
(d/without-nils))))
|
||||
|
||||
([conn file-id object-ids]
|
||||
(let [sql (str/concat
|
||||
"select object_id, data, media_id "
|
||||
" from file_object_thumbnail"
|
||||
" where file_id=? and object_id = ANY(?)")
|
||||
ids (db/create-array conn "text" (seq object-ids))
|
||||
res (db/exec! conn [sql file-id ids])]
|
||||
(d/index-by :object-id
|
||||
(fn [row]
|
||||
(or (some-> row :media-id files/resolve-public-uri)
|
||||
(:data row)))
|
||||
res))))
|
||||
|
||||
(sv/defmethod ::get-file-object-thumbnails
|
||||
"Retrieve a file object thumbnails."
|
||||
{::doc/added "1.17"
|
||||
::sm/params [:map {:title "get-file-object-thumbnails"}
|
||||
[:file-id ::sm/uuid]]
|
||||
::sm/result [:map-of :string :string]
|
||||
::cond/get-object #(files/get-minimal-file %1 (:file-id %2))
|
||||
::cond/reuse-key? true
|
||||
::cond/key-fn files/get-file-etag}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}]
|
||||
(dm/with-open [conn (db/open pool)]
|
||||
(files/check-read-permissions! conn profile-id file-id)
|
||||
(get-object-thumbnails conn file-id)))
|
||||
|
||||
;; --- COMMAND QUERY: get-file-thumbnail
|
||||
|
||||
(defn get-file-thumbnail
|
||||
[conn file-id revn]
|
||||
(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! conn sql)]
|
||||
|
||||
(when-not row
|
||||
(ex/raise :type :not-found
|
||||
:code :file-thumbnail-not-found))
|
||||
|
||||
(when-not (:data row)
|
||||
(ex/raise :type :not-found
|
||||
:code :file-thumbnail-not-found))
|
||||
|
||||
{:data (:data row)
|
||||
:props (some-> (:props row) db/decode-transit-pgobject)
|
||||
:revn (:revn row)
|
||||
:file-id (:file-id row)}))
|
||||
|
||||
(s/def ::revn ::us/integer)
|
||||
(s/def ::file-id ::us/uuid)
|
||||
|
||||
(s/def ::get-file-thumbnail
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::file-id]
|
||||
:opt-un [::revn]))
|
||||
|
||||
(sv/defmethod ::get-file-thumbnail
|
||||
{::doc/added "1.17"
|
||||
::doc/deprecated "1.19"}
|
||||
[{:keys [::db/pool]} {:keys [::rpc/profile-id file-id revn]}]
|
||||
(dm/with-open [conn (db/open pool)]
|
||||
(files/check-read-permissions! conn profile-id file-id)
|
||||
(-> (get-file-thumbnail conn file-id revn)
|
||||
(rph/with-http-cache long-cache-duration))))
|
||||
|
||||
;; --- COMMAND QUERY: get-file-data-for-thumbnail
|
||||
|
||||
;; We need to improve how we set frame for thumbnail in order to avoid
|
||||
;; loading all pages into memory for find the frame set for thumbnail.
|
||||
|
||||
(defn get-file-data-for-thumbnail
|
||||
[conn {: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]
|
||||
;; NOTE: this is a hack for avoid perform blocking
|
||||
;; operation inside the for loop, clojure lazy-seq uses
|
||||
;; synchronized blocks that does not plays well with
|
||||
;; virtual threads, so we need to perform the load
|
||||
;; operation first. This operation forces all pointer maps
|
||||
;; load into the memory.
|
||||
(->> (-> data :pages-index vals)
|
||||
(filter pmap/pointer-map?)
|
||||
(run! pmap/load!))
|
||||
|
||||
;; Then proceed to find the frame set for thumbnail
|
||||
|
||||
(d/seek :use-for-thumbnail?
|
||||
(for [page (-> data :pages-index vals)
|
||||
frame (-> page :objects ctt/get-frames)]
|
||||
(assoc frame :page-id (:id page)))))
|
||||
|
||||
;; function responsible to filter objects data structure of
|
||||
;; all unneeded 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))
|
||||
|
||||
children-ids
|
||||
(cph/get-children-ids objects frame-id)
|
||||
|
||||
bounds
|
||||
(when (:show-content frame)
|
||||
(gsh/selection-rect (concat [frame] (->> children-ids (map (d/getf objects))))))
|
||||
|
||||
frame
|
||||
(cond-> frame
|
||||
(some? bounds)
|
||||
(assoc :children-bounds bounds))]
|
||||
|
||||
(if (:thumbnail frame)
|
||||
(recur (-> objects
|
||||
(assoc frame-id frame)
|
||||
(d/without-keys children-ids))
|
||||
(rest frames))
|
||||
(recur (assoc objects frame-id frame)
|
||||
(rest frames))))
|
||||
|
||||
objects)))]
|
||||
|
||||
(binding [pmap/*load-fn* (partial files/load-pointer conn id)]
|
||||
(let [frame (get-thumbnail-frame data)
|
||||
frame-id (:id frame)
|
||||
page-id (or (:page-id frame)
|
||||
(-> data :pages first))
|
||||
|
||||
page (dm/get-in data [:pages-index page-id])
|
||||
page (cond-> page (pmap/pointer-map? page) deref)
|
||||
frame-ids (if (some? frame) (list frame-id) (map :id (ctt/get-frames (:objects page))))
|
||||
|
||||
obj-ids (map #(str page-id %) frame-ids)
|
||||
thumbs (get-object-thumbnails conn id obj-ids)]
|
||||
|
||||
(cond-> page
|
||||
;; If we have frame, we need to specify it on the page level
|
||||
;; and remove the all other unrelated objects.
|
||||
(some? frame-id)
|
||||
(-> (assoc :thumbnail-frame-id frame-id)
|
||||
(update :objects filter-objects frame-id))
|
||||
|
||||
;; Assoc the available thumbnails and prune not visible shapes
|
||||
;; for avoid transfer unnecessary data.
|
||||
:always
|
||||
(update :objects assoc-thumbnails page-id thumbs))))))
|
||||
|
||||
(sv/defmethod ::get-file-data-for-thumbnail
|
||||
"Retrieves the data for generate the thumbnail of the file. Used
|
||||
mainly for render thumbnails on dashboard."
|
||||
|
||||
{::doc/added "1.17"
|
||||
::sm/params [:map {:title "get-file-data-for-thumbnail"}
|
||||
[:file-id ::sm/uuid]
|
||||
[:features {:optional true} ::files/features]]
|
||||
::sm/result [:map {:title "PartialFile"}
|
||||
[:id ::sm/uuid]
|
||||
[:revn {:min 0} :int]
|
||||
[:page :any]]}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id features] :as props}]
|
||||
(dm/with-open [conn (db/open pool)]
|
||||
(files/check-read-permissions! conn profile-id file-id)
|
||||
;; NOTE: we force here the "storage/pointer-map" feature, because
|
||||
;; it used internally only and is independent if user supports it
|
||||
;; or not.
|
||||
(let [feat (into #{"storage/pointer-map"} features)
|
||||
file (files/get-file conn file-id feat)]
|
||||
{:file-id file-id
|
||||
:revn (:revn file)
|
||||
:page (get-file-data-for-thumbnail conn file)})))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; MUTATION COMMANDS
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
;; --- MUTATION COMMAND: upsert-file-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 = ?;")
|
||||
|
||||
(defn upsert-file-object-thumbnail!
|
||||
[conn {:keys [file-id object-id data]}]
|
||||
(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})))
|
||||
|
||||
(s/def ::data (s/nilable ::us/string))
|
||||
(s/def ::object-id ::us/string)
|
||||
|
||||
(s/def ::upsert-file-object-thumbnail
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::file-id ::object-id]
|
||||
:opt-un [::data]))
|
||||
|
||||
(sv/defmethod ::upsert-file-object-thumbnail
|
||||
{::doc/added "1.17"
|
||||
::doc/deprecated "1.19"
|
||||
::audit/skip true}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(files/check-edition-permissions! conn profile-id file-id)
|
||||
|
||||
(when-not (db/read-only? conn)
|
||||
(upsert-file-object-thumbnail! conn params)
|
||||
nil)))
|
||||
|
||||
|
||||
;; --- MUTATION COMMAND: create-file-object-thumbnail
|
||||
|
||||
(def ^:private sql:create-object-thumbnail
|
||||
"insert into file_object_thumbnail(file_id, object_id, media_id)
|
||||
values (?, ?, ?)
|
||||
on conflict(file_id, object_id) do
|
||||
update set media_id = ?;")
|
||||
|
||||
(defn- create-file-object-thumbnail!
|
||||
[{:keys [::db/conn ::sto/storage]} file-id object-id media]
|
||||
|
||||
(let [path (:path media)
|
||||
mtype (:mtype media)
|
||||
hash (sto/calculate-hash path)
|
||||
data (-> (sto/content path)
|
||||
(sto/wrap-with-hash hash))
|
||||
media (sto/put-object! storage
|
||||
{::sto/content data
|
||||
::sto/deduplicate? false
|
||||
:content-type mtype
|
||||
:bucket "file-object-thumbnail"})]
|
||||
|
||||
(db/exec-one! conn [sql:create-object-thumbnail file-id object-id
|
||||
(:id media) (:id media)])))
|
||||
|
||||
|
||||
(def schema:create-file-object-thumbnail
|
||||
[:map {:title "create-file-object-thumbnail"}
|
||||
[:file-id ::sm/uuid]
|
||||
[:object-id :string]
|
||||
[:media ::media/upload]])
|
||||
|
||||
(sv/defmethod ::create-file-object-thumbnail
|
||||
{:doc/added "1.19"
|
||||
::audit/skip true
|
||||
::sm/params schema:create-file-object-thumbnail}
|
||||
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id object-id media]}]
|
||||
(db/with-atomic [conn pool]
|
||||
(files/check-edition-permissions! conn profile-id file-id)
|
||||
(media/validate-media-type! media)
|
||||
(media/validate-media-size! media)
|
||||
|
||||
(when-not (db/read-only? conn)
|
||||
(-> cfg
|
||||
(update ::sto/storage media/configure-assets-storage)
|
||||
(assoc ::db/conn conn)
|
||||
(create-file-object-thumbnail! file-id object-id media))
|
||||
nil)))
|
||||
|
||||
;; --- MUTATION COMMAND: delete-file-object-thumbnail
|
||||
|
||||
(defn- delete-file-object-thumbnail!
|
||||
[{:keys [::db/conn ::sto/storage]} file-id object-id]
|
||||
(when-let [{:keys [media-id]} (db/get* conn :file-object-thumbnail
|
||||
{:file-id file-id
|
||||
:object-id object-id}
|
||||
{::db/for-update? true})]
|
||||
(when media-id
|
||||
(sto/del-object! storage media-id))
|
||||
|
||||
(db/delete! conn :file-object-thumbnail
|
||||
{:file-id file-id
|
||||
:object-id object-id})
|
||||
nil))
|
||||
|
||||
(s/def ::delete-file-object-thumbnail
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::file-id ::object-id]))
|
||||
|
||||
(sv/defmethod ::delete-file-object-thumbnail
|
||||
{:doc/added "1.19"
|
||||
::audit/skip true}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id object-id]}]
|
||||
|
||||
(db/with-atomic [conn pool]
|
||||
(files/check-edition-permissions! conn profile-id file-id)
|
||||
|
||||
(when-not (db/read-only? conn)
|
||||
(-> cfg
|
||||
(update ::sto/storage media/configure-assets-storage)
|
||||
(assoc ::db/conn conn)
|
||||
(delete-file-object-thumbnail! file-id object-id))
|
||||
nil)))
|
||||
|
||||
;; --- MUTATION COMMAND: upsert-file-thumbnail
|
||||
|
||||
(def ^:private 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();")
|
||||
|
||||
(defn- upsert-file-thumbnail!
|
||||
[conn {:keys [file-id revn data props]}]
|
||||
(let [props (db/tjson (or props {}))]
|
||||
(db/exec-one! conn [sql:upsert-file-thumbnail
|
||||
file-id revn data props data props])))
|
||||
|
||||
(s/def ::revn ::us/integer)
|
||||
(s/def ::props map?)
|
||||
|
||||
(s/def ::upsert-file-thumbnail
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::file-id ::revn ::props ::data]))
|
||||
|
||||
(sv/defmethod ::upsert-file-thumbnail
|
||||
"Creates or updates the file thumbnail. Mainly used for paint the
|
||||
grid thumbnails."
|
||||
{::doc/added "1.17"
|
||||
::doc/deprecated "1.19"
|
||||
::audit/skip true}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(files/check-edition-permissions! conn profile-id file-id)
|
||||
(when-not (db/read-only? conn)
|
||||
(upsert-file-thumbnail! conn params)
|
||||
nil)))
|
||||
|
||||
;; --- MUTATION COMMAND: create-file-thumbnail
|
||||
|
||||
(def ^:private sql:create-file-thumbnail
|
||||
"insert into file_thumbnail (file_id, revn, media_id, props)
|
||||
values (?, ?, ?, ?::jsonb)
|
||||
on conflict(file_id, revn) do
|
||||
update set media_id=?, props=?, updated_at=now();")
|
||||
|
||||
(defn- create-file-thumbnail!
|
||||
[{:keys [::db/conn ::sto/storage]} {:keys [file-id revn props media] :as params}]
|
||||
(media/validate-media-type! media)
|
||||
(media/validate-media-size! media)
|
||||
|
||||
(let [props (db/tjson (or props {}))
|
||||
path (:path media)
|
||||
mtype (:mtype media)
|
||||
hash (sto/calculate-hash path)
|
||||
data (-> (sto/content path)
|
||||
(sto/wrap-with-hash hash))
|
||||
media (sto/put-object! storage
|
||||
{::sto/content data
|
||||
::sto/deduplicate? false
|
||||
:content-type mtype
|
||||
:bucket "file-thumbnail"})]
|
||||
(db/exec-one! conn [sql:create-file-thumbnail file-id revn
|
||||
(:id media) props
|
||||
(:id media) props])
|
||||
media))
|
||||
|
||||
(sv/defmethod ::create-file-thumbnail
|
||||
"Creates or updates the file thumbnail. Mainly used for paint the
|
||||
grid thumbnails."
|
||||
{::doc/added "1.19"
|
||||
::audit/skip true
|
||||
::sm/params [:map {:title "create-file-thumbnail"}
|
||||
[:file-id ::sm/uuid]
|
||||
[:revn :int]
|
||||
[:media ::media/upload]]
|
||||
}
|
||||
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(files/check-edition-permissions! conn profile-id file-id)
|
||||
(when-not (db/read-only? conn)
|
||||
(let [media (-> cfg
|
||||
(update ::sto/storage media/configure-assets-storage)
|
||||
(assoc ::db/conn conn)
|
||||
(create-file-thumbnail! params))]
|
||||
|
||||
{:uri (files/resolve-public-uri (:id media))}))))
|
||||
@@ -4,13 +4,16 @@
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC
|
||||
|
||||
(ns app.rpc.commands.files.update
|
||||
(ns app.rpc.commands.files-update
|
||||
(:require
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.files.features :as ffeat]
|
||||
[app.common.logging :as l]
|
||||
[app.common.pages :as cp]
|
||||
[app.common.pages.changes :as cpc]
|
||||
[app.common.pages.migrations :as pmg]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.schema.generators :as smg]
|
||||
[app.common.spec :as us]
|
||||
[app.common.types.file :as ctf]
|
||||
[app.common.uuid :as uuid]
|
||||
@@ -21,7 +24,7 @@
|
||||
[app.metrics :as mtx]
|
||||
[app.msgbus :as mbus]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.climit :as-alias climit]
|
||||
[app.rpc.climit :as climit]
|
||||
[app.rpc.commands.files :as files]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.rpc.helpers :as rph]
|
||||
@@ -60,6 +63,40 @@
|
||||
(or (contains? o :changes)
|
||||
(contains? o :changes-with-metadata)))))
|
||||
|
||||
|
||||
;; --- SCHEMA
|
||||
|
||||
(sm/def! ::changes
|
||||
[:vector ::cpc/change])
|
||||
|
||||
(sm/def! ::change-with-metadata
|
||||
[:map {:title "ChangeWithMetadata"}
|
||||
[:changes ::changes]
|
||||
[:hint-origin {:optional true} :keyword]
|
||||
[:hint-events {:optional true} [:vector :string]]])
|
||||
|
||||
(sm/def! ::update-file-params
|
||||
[:map {:title "UpdateFileParams"}
|
||||
[:id ::sm/uuid]
|
||||
[:session-id ::sm/uuid]
|
||||
[:revn {:min 0} :int]
|
||||
[:features {:optional true
|
||||
:gen/max 3
|
||||
:gen/gen (smg/subseq files/supported-features)}
|
||||
::sm/set-of-strings]
|
||||
[:changes {:optional true} ::changes]
|
||||
[:changes-with-metadata {:optional true}
|
||||
[:vector ::change-with-metadata]]])
|
||||
|
||||
(sm/def! ::update-file-result
|
||||
[:vector {:title "UpdateFileResults"}
|
||||
[:map {:title "UpdateFileResult"}
|
||||
[:changes ::changes]
|
||||
[:file-id ::sm/uuid]
|
||||
[:id ::sm/uuid]
|
||||
[:revn {:min 0} :int]
|
||||
[:session-id ::sm/uuid]]])
|
||||
|
||||
;; --- HELPERS
|
||||
|
||||
;; File changes that affect to the library, and must be notified
|
||||
@@ -78,8 +115,7 @@
|
||||
(defn- library-change?
|
||||
[{:keys [type] :as change}]
|
||||
(or (contains? library-change-types type)
|
||||
(and (contains? file-change-types type)
|
||||
(some? (:component-id change)))))
|
||||
(contains? file-change-types type)))
|
||||
|
||||
(def ^:private sql:get-file
|
||||
"SELECT f.*, p.team_id
|
||||
@@ -101,7 +137,7 @@
|
||||
|
||||
(defn- wrap-with-pointer-map-context
|
||||
[f]
|
||||
(fn [{:keys [conn] :as cfg} {:keys [id] :as file}]
|
||||
(fn [{:keys [::db/conn] :as cfg} {:keys [id] :as file}]
|
||||
(binding [pmap/*tracked* (atom {})
|
||||
pmap/*load-fn* (partial files/load-pointer conn id)
|
||||
ffeat/*wrap-with-pointer-map-fn* pmap/wrap]
|
||||
@@ -126,18 +162,22 @@
|
||||
;; database.
|
||||
|
||||
(sv/defmethod ::update-file
|
||||
{::climit/queue :update-file
|
||||
{::climit/id :update-file-by-id
|
||||
::climit/key-fn :id
|
||||
::webhooks/event? true
|
||||
::webhooks/batch-timeout (dt/duration "2m")
|
||||
::webhooks/batch-key (webhooks/key-fn ::rpc/profile-id :id)
|
||||
|
||||
::sm/params ::update-file-params
|
||||
::sm/result ::update-file-result
|
||||
|
||||
::doc/module :files
|
||||
::doc/added "1.17"}
|
||||
[{:keys [pool] :as cfg} {:keys [::rpc/profile-id id] :as params}]
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id id] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(files/check-edition-permissions! conn profile-id id)
|
||||
(db/xact-lock! conn id)
|
||||
|
||||
(let [cfg (assoc cfg :conn conn)
|
||||
(let [cfg (assoc cfg ::db/conn conn)
|
||||
params (assoc params :profile-id profile-id)
|
||||
tpoint (dt/tpoint)]
|
||||
(-> (update-file cfg params)
|
||||
@@ -145,17 +185,18 @@
|
||||
(l/trace :hint "update-file" :time (dt/format-duration elapsed))))))))
|
||||
|
||||
(defn update-file
|
||||
[{:keys [conn metrics] :as cfg} {:keys [profile-id id changes changes-with-metadata] :as params}]
|
||||
[{:keys [::db/conn ::mtx/metrics] :as cfg} {:keys [profile-id id changes changes-with-metadata] :as params}]
|
||||
(let [file (get-file conn id)
|
||||
features (->> (concat (:features file)
|
||||
(:features params))
|
||||
(into files/default-features)
|
||||
(into (files/get-default-features))
|
||||
(files/check-features-compatibility!))]
|
||||
|
||||
(files/check-edition-permissions! conn profile-id (:id file))
|
||||
|
||||
(binding [ffeat/*current* features
|
||||
ffeat/*previous* (:features file)]
|
||||
|
||||
(let [update-fn (cond-> update-file*
|
||||
(contains? features "storage/pointer-map")
|
||||
(wrap-with-pointer-map-context)
|
||||
@@ -197,24 +238,34 @@
|
||||
:project-id (:project-id file)
|
||||
:team-id (:team-id file)}))))))
|
||||
|
||||
(defn- update-file-data
|
||||
[file changes]
|
||||
(-> file
|
||||
(update :revn inc)
|
||||
(update :data (fn [data]
|
||||
(cond-> data
|
||||
:always
|
||||
(-> (blob/decode)
|
||||
(assoc :id (:id file))
|
||||
(pmg/migrate-data))
|
||||
|
||||
(and (contains? ffeat/*current* "components/v2")
|
||||
(not (contains? ffeat/*previous* "components/v2")))
|
||||
(ctf/migrate-to-components-v2)
|
||||
|
||||
:always
|
||||
(-> (cp/process-changes changes)
|
||||
(blob/encode)))))))
|
||||
|
||||
|
||||
(defn- update-file*
|
||||
[{:keys [conn] :as cfg} {:keys [profile-id file changes session-id ::created-at] :as params}]
|
||||
(let [file (-> file
|
||||
(update :revn inc)
|
||||
(update :data (fn [data]
|
||||
(cond-> data
|
||||
:always
|
||||
(-> (blob/decode)
|
||||
(assoc :id (:id file))
|
||||
(pmg/migrate-data))
|
||||
[{:keys [::db/conn] :as cfg} {:keys [profile-id file changes session-id ::created-at] :as params}]
|
||||
(let [;; Process the file data in the CLIMIT context; scheduling it
|
||||
;; to be executed on a separated executor for avoid to do the
|
||||
;; CPU intensive operation on vthread.
|
||||
file (-> (climit/configure cfg :update-file)
|
||||
(climit/submit! (partial update-file-data file changes)))]
|
||||
|
||||
(and (contains? ffeat/*current* "components/v2")
|
||||
(not (contains? ffeat/*previous* "components/v2")))
|
||||
(ctf/migrate-to-components-v2)
|
||||
|
||||
:always
|
||||
(-> (cp/process-changes changes)
|
||||
(blob/encode))))))]
|
||||
(db/insert! conn :file-change
|
||||
{:id (uuid/next)
|
||||
:session-id session-id
|
||||
@@ -273,11 +324,10 @@
|
||||
(vec)))
|
||||
|
||||
(defn- send-notifications!
|
||||
[{:keys [conn] :as cfg} {:keys [file changes session-id] :as params}]
|
||||
[{:keys [::db/conn] :as cfg} {:keys [file changes session-id] :as params}]
|
||||
(let [lchanges (filter library-change? changes)
|
||||
msgbus (:msgbus cfg)]
|
||||
msgbus (::mbus/msgbus cfg)]
|
||||
|
||||
;; Asynchronously publish message to the msgbus
|
||||
(mbus/pub! msgbus
|
||||
:topic (:id file)
|
||||
:message {:type :file-change
|
||||
@@ -290,7 +340,6 @@
|
||||
(when (and (:is-shared file) (seq lchanges))
|
||||
(let [team-id (or (:team-id file)
|
||||
(files/get-team-id conn (:project-id file)))]
|
||||
;; Asynchronously publish message to the msgbus
|
||||
(mbus/pub! msgbus
|
||||
:topic team-id
|
||||
:message {:type :library-change
|
||||
229
backend/src/app/rpc/commands/fonts.clj
Normal file
229
backend/src/app/rpc/commands/fonts.clj
Normal file
@@ -0,0 +1,229 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC
|
||||
|
||||
(ns app.rpc.commands.fonts
|
||||
(:require
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.spec :as us]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.db :as db]
|
||||
[app.loggers.audit :as-alias audit]
|
||||
[app.loggers.webhooks :as-alias webhooks]
|
||||
[app.media :as media]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.climit :as climit]
|
||||
[app.rpc.commands.files :as files]
|
||||
[app.rpc.commands.projects :as projects]
|
||||
[app.rpc.commands.teams :as teams]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.rpc.helpers :as rph]
|
||||
[app.rpc.quotes :as quotes]
|
||||
[app.storage :as sto]
|
||||
[app.util.services :as sv]
|
||||
[app.util.time :as dt]
|
||||
[clojure.spec.alpha :as s]))
|
||||
|
||||
(def valid-weight #{100 200 300 400 500 600 700 800 900 950})
|
||||
(def valid-style #{"normal" "italic"})
|
||||
|
||||
(s/def ::data (s/map-of ::us/string any?))
|
||||
(s/def ::file-id ::us/uuid)
|
||||
(s/def ::font-id ::us/uuid)
|
||||
(s/def ::id ::us/uuid)
|
||||
(s/def ::name ::us/not-empty-string)
|
||||
(s/def ::project-id ::us/uuid)
|
||||
(s/def ::share-id ::us/uuid)
|
||||
(s/def ::style valid-style)
|
||||
(s/def ::team-id ::us/uuid)
|
||||
(s/def ::weight valid-weight)
|
||||
|
||||
;; --- QUERY: Get font variants
|
||||
|
||||
(s/def ::get-font-variants
|
||||
(s/and
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:opt-un [::team-id
|
||||
::file-id
|
||||
::project-id
|
||||
::share-id])
|
||||
(fn [o]
|
||||
(or (contains? o :team-id)
|
||||
(contains? o :file-id)
|
||||
(contains? o :project-id)))))
|
||||
|
||||
(sv/defmethod ::get-font-variants
|
||||
{::doc/added "1.18"}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id file-id project-id share-id] :as params}]
|
||||
(dm/with-open [conn (db/open pool)]
|
||||
(cond
|
||||
(uuid? team-id)
|
||||
(do
|
||||
(teams/check-read-permissions! conn profile-id team-id)
|
||||
(db/query conn :team-font-variant
|
||||
{:team-id team-id
|
||||
:deleted-at nil}))
|
||||
|
||||
(uuid? project-id)
|
||||
(let [project (db/get-by-id conn :project project-id {:columns [:id :team-id]})]
|
||||
(projects/check-read-permissions! conn profile-id project-id)
|
||||
(db/query conn :team-font-variant
|
||||
{:team-id (:team-id project)
|
||||
:deleted-at nil}))
|
||||
|
||||
(uuid? file-id)
|
||||
(let [file (db/get-by-id conn :file file-id {:columns [:id :project-id]})
|
||||
project (db/get-by-id conn :project (:project-id file) {:columns [:id :team-id]})
|
||||
perms (files/get-permissions conn profile-id file-id share-id)]
|
||||
(files/check-read-permissions! perms)
|
||||
(db/query conn :team-font-variant
|
||||
{:team-id (:team-id project)
|
||||
:deleted-at nil})))))
|
||||
|
||||
|
||||
(declare create-font-variant)
|
||||
|
||||
(s/def ::create-font-variant
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::team-id
|
||||
::data
|
||||
::font-id
|
||||
::font-family
|
||||
::font-weight
|
||||
::font-style]))
|
||||
|
||||
(sv/defmethod ::create-font-variant
|
||||
{::doc/added "1.18"
|
||||
::webhooks/event? true}
|
||||
[{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id] :as params}]
|
||||
(let [cfg (update cfg ::sto/storage media/configure-assets-storage)]
|
||||
(teams/check-edition-permissions! pool profile-id team-id)
|
||||
(quotes/check-quote! pool {::quotes/id ::quotes/font-variants-per-team
|
||||
::quotes/profile-id profile-id
|
||||
::quotes/team-id team-id})
|
||||
(create-font-variant cfg (assoc params :profile-id profile-id))))
|
||||
|
||||
(defn create-font-variant
|
||||
[{:keys [::sto/storage ::db/pool] :as cfg} {:keys [data] :as params}]
|
||||
(letfn [(generate-missing! [data]
|
||||
(let [data (media/run {:cmd :generate-fonts :input 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
|
||||
:hint "invalid font upload, unable to generate missing font assets"))
|
||||
data))
|
||||
|
||||
(prepare-font [data mtype]
|
||||
(when-let [resource (get data mtype)]
|
||||
(let [hash (sto/calculate-hash resource)
|
||||
content (-> (sto/content resource)
|
||||
(sto/wrap-with-hash hash))]
|
||||
{::sto/content content
|
||||
::sto/touched-at (dt/now)
|
||||
::sto/deduplicate? true
|
||||
:content-type mtype
|
||||
:bucket "team-font-variant"})))
|
||||
|
||||
(persist-fonts-files! [data]
|
||||
(let [otf-params (prepare-font data "font/otf")
|
||||
ttf-params (prepare-font data "font/ttf")
|
||||
wf1-params (prepare-font data "font/woff")
|
||||
wf2-params (prepare-font data "font/woff2")]
|
||||
(cond-> {}
|
||||
(some? otf-params)
|
||||
(assoc :otf (sto/put-object! storage otf-params))
|
||||
(some? ttf-params)
|
||||
(assoc :ttf (sto/put-object! storage ttf-params))
|
||||
(some? wf1-params)
|
||||
(assoc :woff1 (sto/put-object! storage wf1-params))
|
||||
(some? wf2-params)
|
||||
(assoc :woff2 (sto/put-object! storage wf2-params)))))
|
||||
|
||||
(insert-font-variant! [{: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)}))
|
||||
]
|
||||
|
||||
(let [data (-> (climit/configure cfg :process-font)
|
||||
(climit/submit! (partial generate-missing! data)))
|
||||
assets (persist-fonts-files! data)
|
||||
result (insert-font-variant! assets)]
|
||||
(vary-meta result assoc ::audit/replace-props (update params :data (comp vec keys))))))
|
||||
|
||||
;; --- UPDATE FONT FAMILY
|
||||
|
||||
(s/def ::update-font
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::team-id ::id ::name]))
|
||||
|
||||
(sv/defmethod ::update-font
|
||||
{::doc/added "1.18"
|
||||
::webhooks/event? true}
|
||||
[{:keys [::db/pool]} {:keys [::rpc/profile-id team-id id name]}]
|
||||
(db/with-atomic [conn pool]
|
||||
(teams/check-edition-permissions! conn profile-id team-id)
|
||||
(rph/with-meta
|
||||
(db/update! conn :team-font-variant
|
||||
{:font-family name}
|
||||
{:font-id id
|
||||
:team-id team-id})
|
||||
{::audit/replace-props {:id id
|
||||
:name name
|
||||
:team-id team-id
|
||||
:profile-id profile-id}})))
|
||||
|
||||
;; --- DELETE FONT
|
||||
|
||||
(s/def ::delete-font
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::team-id ::id]))
|
||||
|
||||
(sv/defmethod ::delete-font
|
||||
{::doc/added "1.18"
|
||||
::webhooks/event? true}
|
||||
[{:keys [::db/pool]} {:keys [::rpc/profile-id id team-id]}]
|
||||
(db/with-atomic [conn pool]
|
||||
(teams/check-edition-permissions! conn profile-id team-id)
|
||||
(let [font (db/update! conn :team-font-variant
|
||||
{:deleted-at (dt/now)}
|
||||
{:font-id id :team-id team-id})]
|
||||
(rph/with-meta (rph/wrap)
|
||||
{::audit/props {:id id
|
||||
:team-id team-id
|
||||
:name (:font-family font)
|
||||
:profile-id profile-id}}))))
|
||||
|
||||
;; --- DELETE FONT VARIANT
|
||||
|
||||
(s/def ::delete-font-variant
|
||||
(s/keys :req [::rpc/profile-id]
|
||||
:req-un [::team-id ::id]))
|
||||
|
||||
(sv/defmethod ::delete-font-variant
|
||||
{::doc/added "1.18"
|
||||
::webhooks/event? true}
|
||||
[{:keys [::db/pool]} {:keys [::rpc/profile-id id team-id]}]
|
||||
(db/with-atomic [conn pool]
|
||||
(teams/check-edition-permissions! conn profile-id team-id)
|
||||
(let [variant (db/update! conn :team-font-variant
|
||||
{:deleted-at (dt/now)}
|
||||
{:id id :team-id team-id})]
|
||||
(rph/with-meta (rph/wrap)
|
||||
{::audit/props {:font-family (:font-family variant)
|
||||
:font-id (:font-id variant)}}))))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user