Compare commits
1992 commits
unread_cou
...
main
Author | SHA1 | Date | |
---|---|---|---|
f55ef1d7ef | |||
14bc9f0946 | |||
493598c1ba | |||
96efe302ce | |||
e25bcb35d7 | |||
05b485b678 | |||
360d4ea8d1 | |||
c06d612432 | |||
c88722983e | |||
33a326854a | |||
9930c7288a | |||
8d9fab0389 | |||
c3efb9f7cf | |||
5899b89ef2 | |||
99e5a4d1c3 | |||
|
db4fe8031c | ||
270ce539bf | |||
b9f483bc27 | |||
8ee624a542 | |||
621355b6ef | |||
72b5e0cab5 | |||
|
74272ed754 | ||
|
4426c3176d | ||
|
7b0a09e84e | ||
|
ab947f1f08 | ||
5998c83b2a | |||
434fb53dd1 | |||
75a95acf04 | |||
|
931a132161 | ||
0a7271a185 | |||
7c039340ed | |||
7d04f371a5 | |||
5d8ccbafe4 | |||
1a4c8c08ee | |||
9cb4dad4b4 | |||
ddf4a667b1 | |||
|
fc74bfeb23 | ||
8f6b8895f4 | |||
|
a650312858 | ||
ff2c71a74a | |||
|
126c6a23bb | ||
e0c61c1334 | |||
f7aa97d45e | |||
a1c7584875 | |||
817b4ff08e | |||
ca3c1269f5 | |||
|
0a52396706 | ||
|
7c4969c92b | ||
|
45a94203f2 | ||
7189328f80 | |||
|
134fece36d | ||
985dbcaada | |||
|
e78ba38e94 | ||
7f56281c26 | |||
|
45e05dac30 | ||
66946117e1 | |||
|
462c4a2954 | ||
|
5ce8adcb13 | ||
3bdd78f341 | |||
|
b5aa4cf41a | ||
|
a5e2463097 | ||
a869a2823b | |||
|
ff3e26452a | ||
|
da5b27ecc6 | ||
c618b4efaa | |||
4cc341e4aa | |||
82d97cf6de | |||
|
600ae662a5 | ||
efc9047f87 | |||
aba32917bd | |||
ea3c0e1772 | |||
058052b46c | |||
|
7c87da012e | ||
bf1e859e72 | |||
289cef3101 | |||
085c307b8b | |||
72783edb17 | |||
3141ad31de | |||
|
40ceec9737 | ||
723ec65ac6 | |||
3ae62573b7 | |||
|
6499709221 | ||
813fcabe13 | |||
92ea9b97dd | |||
|
0c9b109bf7 | ||
|
2cbd158a11 | ||
|
7dc3ff4544 | ||
8d5e9f865c | |||
8096765f0e | |||
8eb81bb153 | |||
c81435c994 | |||
7548b44d1b | |||
bcc8dae16b | |||
b014ac44c8 | |||
a806493bc2 | |||
b593047fb1 | |||
9845366a36 | |||
|
15d0f54a88 | ||
|
8088055d38 | ||
|
0c4b57a6d0 | ||
d3707ad4ef | |||
13a949d9ec | |||
6f683682c3 | |||
a920bf768e | |||
a183815870 | |||
d0bd02eea0 | |||
37ea778776 | |||
|
f37fd0ecfd | ||
3d400ca21d | |||
71aa8f3670 | |||
37ad9e9a09 | |||
1af906c224 | |||
|
f899831ed3 | ||
08900b5b94 | |||
5a33fce8bd | |||
acadf0289e | |||
2e5ccaf7fe | |||
63d9c0ee46 | |||
|
68edda7bf5 | ||
999d9f4d6c | |||
ce00677880 | |||
5656db3e3d | |||
c213edf7ee | |||
a7540b4947 | |||
5f112aad44 | |||
f198f281cf | |||
bf751dc7ab | |||
|
6f364e60fa | ||
2b6df63aee | |||
|
def8af7d8a | ||
14465b91b1 | |||
d09df9c02b | |||
897263e5a3 | |||
4864f80656 | |||
105dfc93f1 | |||
|
d5d99fa3b9 | ||
8a7e50381f | |||
f45f2ec202 | |||
|
1a4e35eb50 | ||
ed8a12f96f | |||
525fdcf73c | |||
840ab7cba4 | |||
9415bec557 | |||
712d497a2d | |||
dbc94af51d | |||
86d8c9b18e | |||
1857f02af8 | |||
10f0b3b877 | |||
|
0be9b5bddb | ||
|
6bb4f0b41f | ||
|
f4d33389a5 | ||
c8254dc0a8 | |||
6b36bf772e | |||
d2ba2960dd | |||
cd08fdf76f | |||
aecb2411d8 | |||
9609bd99bb | |||
|
5102cdddc1 | ||
3a05817b41 | |||
51465bc0d7 | |||
2322534648 | |||
3f23e0e6b9 | |||
e6a16f08a3 | |||
|
0fd0279543 | ||
|
f5e58c8bf5 | ||
5f59d7ba5f | |||
62a145d8b3 | |||
c09c462a6e | |||
3d578f9df2 | |||
363ceea5c7 | |||
c51f750831 | |||
|
cf911c023d | ||
ea59cf16e8 | |||
91d210ce79 | |||
f0dcc3a104 | |||
b2d6b554e8 | |||
1addbe361a | |||
|
97617d699d | ||
ac969dc737 | |||
c014bef84d | |||
f2f9f5776c | |||
cb9c354c28 | |||
|
b1fb90ff6d | ||
|
ee03cf8ae9 | ||
|
a01af67948 | ||
3b64c58198 | |||
|
88284a999e | ||
856802ef35 | |||
|
24c78de5f0 | ||
1de8a4606a | |||
672d4507b2 | |||
|
25dd1a21e2 | ||
6f2954dffd | |||
8cfee9ca7d | |||
b124a29e05 | |||
|
b3163f99f4 | ||
fe4b516bd9 | |||
f0d928a433 | |||
edf0fd4381 | |||
|
1de737b7a3 | ||
|
29ec5d4855 | ||
|
381fd82c13 | ||
8f61a148f6 | |||
a4f6ca0c9c | |||
15710a0595 | |||
f06b71d961 | |||
ccd2b9eb75 | |||
c8a8670aec | |||
|
110167f085 | ||
|
66102fb2d4 | ||
4fdcb57753 | |||
|
8b4a16a3f3 | ||
5c198ea85d | |||
15c5e5c502 | |||
116d908002 | |||
0c932e3ace | |||
|
6a04aaca55 | ||
a10974ed6e | |||
cd19a72c41 | |||
36976acb2f | |||
4677d3d782 | |||
82227846af | |||
eafdf3033f | |||
d54be4ed7f | |||
a1e5d0fd00 | |||
d4e800175f | |||
39001af9a0 | |||
|
c6357f3c86 | ||
|
4eedc31893 | ||
3d4cc32525 | |||
7db754e94c | |||
e483b6b51f | |||
689f5c1306 | |||
fec77d583f | |||
7a97fc370b | |||
b9b51c2dfc | |||
ceae7eb47a | |||
1c113f915e | |||
514f4011ba | |||
a56977f4c5 | |||
86dfe456fd | |||
50e7275c3b | |||
1e0c32f7a3 | |||
d227000de3 | |||
61c6f33103 | |||
d300968ee8 | |||
7bc9434596 | |||
|
632d8f384a | ||
bcb0c381e5 | |||
|
dd069de519 | ||
418eb8025c | |||
9ab3a9d072 | |||
4c681eb48b | |||
58281208b9 | |||
5a16d43fef | |||
95e30f0e08 | |||
d5efebbf47 | |||
e5a65d5807 | |||
1a0d1f64f0 | |||
bd06dd53e3 | |||
5231666465 | |||
a7e231b35b | |||
e25436576a | |||
8e1f41f1e4 | |||
04ce64e9b6 | |||
2aa8de87b2 | |||
929f1d02b5 | |||
d767dd998e | |||
5af8257e19 | |||
a27b7f8d1f | |||
2d7d9cf7d8 | |||
f842bbff8d | |||
114f3cbfb5 | |||
3f36730dba | |||
089d812dc8 | |||
9d0709dfe8 | |||
2e5297e337 | |||
5c266302c5 | |||
6cc148f6a6 | |||
1607930d07 | |||
1a4e2f4770 | |||
caaf6b178b | |||
6d96f105c6 | |||
583808d5e7 | |||
5768a4eda7 | |||
4997d4b0b5 | |||
179709cc09 | |||
|
44b72ccbd6 | ||
|
5b2fb07e44 | ||
4c79e26078 | |||
4bf0ec94c8 | |||
4f5e51beb5 | |||
05c3e471ae | |||
1cf520254d | |||
313f0467c8 | |||
bd6a4a54a9 | |||
57c2f2ef1c | |||
db0a51de2a | |||
711db4c790 | |||
cbd02f2a87 | |||
|
036161cb38 | ||
c947539301 | |||
471abf7f29 | |||
e4714627a4 | |||
998e824bd8 | |||
e492cce206 | |||
d5955b60c0 | |||
f33577b317 | |||
a455e8c0ab | |||
79a960d8a5 | |||
f456f5da46 | |||
ccd26bfcd7 | |||
f6ba6d5590 | |||
dfe17662df | |||
28c217eb66 | |||
d594005d49 | |||
4410d2e44d | |||
5dff60adc5 | |||
9e5824df85 | |||
543be801ab | |||
ab6b28ee60 | |||
bdd264cd5e | |||
5ae3f59092 | |||
0c89e9c2d6 | |||
30a1a69850 | |||
e7a5eff061 | |||
446ae301f8 | |||
2dd3eee0dd | |||
9c7f2cb0c3 | |||
b61bfcefa7 | |||
9cb7680211 | |||
20115444b6 | |||
7c12b1026c | |||
35bf50ab15 | |||
5b376de5f5 | |||
2259b7ebc2 | |||
d859844fea | |||
a56db9a47c | |||
cbe5cf8ca0 | |||
6075f7d2f1 | |||
55e3f370fd | |||
b11b3f4fad | |||
135d654999 | |||
6d1053b8e5 | |||
71d3457b82 | |||
6fec2e56f6 | |||
d9f0aa223a | |||
8b0374cc4e | |||
08748bbede | |||
7e7e27a7eb | |||
77b7511235 | |||
df2ec0aa38 | |||
afc79ce0e3 | |||
eef93440d0 | |||
4fd6b5f5e1 | |||
405e7eff27 | |||
606bfa89b6 | |||
964332db12 | |||
f7cdadc9c2 | |||
e64f196c0d | |||
ed29e3d934 | |||
a7413723b4 | |||
11c9559ef8 | |||
7af4a60ec4 | |||
7b2c59bd98 | |||
861e38d157 | |||
580e397525 | |||
7717deda0e | |||
cdcbef088d | |||
fadb2b46f5 | |||
a30199d879 | |||
6ff7debbdf | |||
f8a196faaf | |||
94d6ceb4df | |||
200913f631 | |||
53c4aab6af | |||
dd6b539119 | |||
e0bbb58ec5 | |||
048ada462b | |||
e849b22d3c | |||
f76f742ba7 | |||
c939954f84 | |||
56bea54536 | |||
9793a0e521 | |||
a8e0eee7df | |||
ad75f9de4b | |||
2c60215156 | |||
def5276f84 | |||
6391ec16ed | |||
a94fd6aaf6 | |||
f9bd72e1ee | |||
841ad8476c | |||
4eced2518c | |||
331985f0a0 | |||
37fc1d721f | |||
82c3778082 | |||
2d0bf7d40d | |||
fc382e20e1 | |||
a2cd1ff367 | |||
2d88dfdaef | |||
b5b670b8b9 | |||
e02d0f39ec | |||
003852f884 | |||
69c2fe19e4 | |||
b79c10c122 | |||
94ab4f6164 | |||
af2a27935b | |||
5b34d2be6c | |||
ec13759ca6 | |||
b7563bfbf5 | |||
7aa686b650 | |||
d0e730fed4 | |||
58850a0b0c | |||
ef22f70e18 | |||
88cd8b2d74 | |||
84ac188cee | |||
cdc7df8625 | |||
2d011468b4 | |||
4557f2b03d | |||
b83cafc454 | |||
dabcfca67b | |||
09212bb6b7 | |||
d3a89a2a22 | |||
bfd306e9de | |||
cb9a06e249 | |||
f9f95a2b92 | |||
91be01fd63 | |||
04fe7c29cb | |||
df0f609cce | |||
e8ffa283f2 | |||
b04944d9a5 | |||
eb06bbfef7 | |||
0e364a4efe | |||
d3b2ce7b35 | |||
742b78523a | |||
a432f939b6 | |||
|
6908feb3ba | ||
46e38bf714 | |||
5010f693ba | |||
08ab85a9d5 | |||
16deec7443 | |||
9cc1cfc973 | |||
|
860f2f3855 | ||
55e03c4eb4 | |||
fc1d07fce6 | |||
e371ec1dc4 | |||
5e57f1bcad | |||
9884927b8a | |||
f5bef3980a | |||
36f7b20784 | |||
5e510e7a67 | |||
caedb7fcc4 | |||
|
0a12cb0281 | ||
2400a078d7 | |||
028d1d0efc | |||
efdcbc44c4 | |||
|
cf3a98afcc | ||
88d7b0a83c | |||
b92e7eb781 | |||
2d4099577f | |||
6d8f93d8a1 | |||
d66f4e8ac0 | |||
5d44dedfda | |||
eed7eac10b | |||
37e7f1a9a8 | |||
4a94d9182e | |||
7731479607 | |||
8a0336c2ff | |||
ca7224c086 | |||
df8a696bb5 | |||
|
d06b59c92b | ||
|
df913326bd | ||
b587e147b0 | |||
e8116f21cd | |||
|
2b5c69d678 | ||
|
4079235b0d | ||
|
45efa94ba4 | ||
cc8a6bea65 | |||
|
4aa3180027 | ||
|
7f1ab6a5cd | ||
3a90f69efc | |||
2e9164584b | |||
|
9435994405 | ||
8d12c77e26 | |||
8fc4e1ecfe | |||
|
7d7fe5962a | ||
|
961fc9d0ce | ||
|
3ceeecc63e | ||
5b66e4860c | |||
5e10cf69b7 | |||
|
050ca88085 | ||
|
ac330a3f7b | ||
|
01a14e3b3c | ||
a30be1ca5d | |||
68173914ca | |||
f070b1823d | |||
|
2b5feca806 | ||
d75f621152 | |||
8bdbda1db9 | |||
a2d80d8f2e | |||
8b5289d5c7 | |||
8fe578c958 | |||
ac75304a09 | |||
7fe4558bee | |||
f3eebb1dfc | |||
250dcc26be | |||
11fdef56db | |||
d6493f31d9 | |||
8b8d47f6f5 | |||
aaa3fc08af | |||
|
9707de4d4a | ||
a7b72ed5c4 | |||
b2288fcb9a | |||
e0e23c2f9d | |||
cd3f20e49b | |||
|
1e187ab4f8 | ||
aaeb852f23 | |||
|
d26a7ad337 | ||
|
911b26f163 | ||
8920f439ce | |||
405ea38959 | |||
bffc82f752 | |||
e2693a4192 | |||
c7740a7ac5 | |||
|
c038acea5b | ||
48f506277a | |||
b8dc3c11c1 | |||
|
f0e3303aeb | ||
1ba1e466f7 | |||
|
eb690fc39c | ||
14bf45d099 | |||
|
eee394cf85 | ||
8675fed49c | |||
0dcff2e647 | |||
|
1b8ce33aa4 | ||
|
a68cff51b1 | ||
|
9e604b4038 | ||
|
438414a64b | ||
|
30d784c27c | ||
|
070efe72af | ||
|
2cd2a4df45 | ||
|
e8e0890341 | ||
|
d6b1c8df2f | ||
|
6d43202efb | ||
08b8e9999b | |||
|
15c77f85c1 | ||
fb16f47f2f | |||
964d95de5c | |||
64ac4e382a | |||
|
4a61758bde | ||
ff8acfb8de | |||
06e82fe761 | |||
|
94dd335fac | ||
|
437809d337 | ||
e1fd614dd1 | |||
3b4c3ec074 | |||
8803e7834f | |||
b469b6d8d3 | |||
7e13970a4f | |||
|
7c51a36012 | ||
405eb15291 | |||
|
b7d2dac9bf | ||
76193d37da | |||
|
60517f8471 | ||
c6c74ab1e3 | |||
|
9aa700fb10 | ||
d2bea09a60 | |||
45dced3fcb | |||
|
5e2a5c0266 | ||
|
18d9811de7 | ||
|
cca0b4c0f9 | ||
eab9123b86 | |||
0ea4095238 | |||
|
b3035e21ef | ||
|
3d872b03e3 | ||
|
1ae335e058 | ||
|
09498d8bb3 | ||
|
bfd8f52497 | ||
|
fc36ae22c9 | ||
1fd5486def | |||
|
7ef044231f | ||
|
9e5e0eb9b7 | ||
|
d3b07c8131 | ||
|
3bf885329d | ||
|
e95ca66c9f | ||
9e24eda752 | |||
77b17c6737 | |||
91d073c2e8 | |||
4c0c558945 | |||
5b871d3dd8 | |||
2e922d602d | |||
3100e8bf21 | |||
b42b461418 | |||
|
604d125a55 | ||
ba0680f5e6 | |||
6eab24270b | |||
|
53c9094d46 | ||
|
60bc62932b | ||
99abc49040 | |||
|
6337762ec7 | ||
c33918c71f | |||
4f0059198e | |||
134d66924e | |||
295c209c67 | |||
5e0562e80e | |||
|
ad1ba85eb9 | ||
|
ac79496036 | ||
6d17d5ead2 | |||
|
cfa60dcb76 | ||
|
52c679a3cd | ||
|
73ccbb1bc8 | ||
|
de8d8542b4 | ||
a897ec48d2 | |||
3d5647b16f | |||
18111629fa | |||
e7d3905093 | |||
b08e0a6415 | |||
|
2527c59e55 | ||
1a3b96b054 | |||
|
3e22c99c96 | ||
a62c96f85e | |||
|
8a6bfa3816 | ||
e8379cb3f7 | |||
|
8bfd678d49 | ||
|
1b62e65a5d | ||
|
dd99e77881 | ||
|
18b3eab909 | ||
|
7673e60654 | ||
695272f980 | |||
e190ecbefb | |||
|
37f616bad2 | ||
06a6bab2c1 | |||
cead2a6303 | |||
0c4f99b161 | |||
815cf60f45 | |||
|
571c71392e | ||
|
24484ed801 | ||
|
3e35e4052f | ||
|
a2df2b12e2 | ||
fe15ff3c51 | |||
39cbe5f31f | |||
|
23793fe096 | ||
c87a009b37 | |||
9bbacd38f4 | |||
c9c3d94bf9 | |||
|
d55e38d4a6 | ||
3f9ede79ed | |||
ac0cd7bc68 | |||
56d361eeff | |||
|
9084a56e36 | ||
7cfcf0acec | |||
2ad60379e4 | |||
0dda2577e1 | |||
cc9bb56d2d | |||
f44612970a | |||
70f7dd876f | |||
b19ac26325 | |||
4010a944a4 | |||
863a662ec6 | |||
08588c873a | |||
a903cae00b | |||
907f8fff4c | |||
1c8913090d | |||
|
e61c3f0de3 | ||
cb4a3a03a2 | |||
c90c96fbf6 | |||
97fc51b0cd | |||
0b4ecdfc05 | |||
875b0e6f01 | |||
03b1821586 | |||
|
8d0580461b | ||
7fbad900d7 | |||
|
f5184ce749 | ||
e9ce14069e | |||
c08d891742 | |||
b4c730e537 | |||
fd257a6d39 | |||
299598f0c4 | |||
f00cfa005e | |||
9dc6294065 | |||
9fe3efcb32 | |||
30431199cf | |||
db7027a367 | |||
26883208cd | |||
60730e81d9 | |||
ca4868cefd | |||
9e84fe20e6 | |||
c6c107cad1 | |||
5e92fb47ed | |||
984f1ae7fb | |||
|
38f4c5f310 | ||
dc18ec533e | |||
07ddeef568 | |||
14c3d10ba9 | |||
4a925c9e9f | |||
5a56c08c91 | |||
|
75ace1192a | ||
c348e788e4 | |||
557d476001 | |||
9b69c446e2 | |||
4d0365bca3 | |||
990987b174 | |||
04a4624f14 | |||
7cc5f53cad | |||
389e929c8e | |||
048fe287c2 | |||
9fa2092a21 | |||
2e2b6eacd7 | |||
eef0a5c7e8 | |||
e596ea6e3a | |||
15adc21e1f | |||
f5b511ccce | |||
2ad137c280 | |||
3a24adc57f | |||
61f013e4cb | |||
6c8a5723f8 | |||
a4cb067130 | |||
b074a963fe | |||
|
0ebd830814 | ||
|
4772cbd23f | ||
1fc21aed1c | |||
c1db1042ad | |||
0aa0ea19fb | |||
ada82582ef | |||
927ab1f040 | |||
|
8bea13d651 | ||
|
10b3d9005f | ||
5dbb2b45cb | |||
|
bfed8a8be4 | ||
4de80dc29d | |||
|
442369a041 | ||
|
e8ea0664ef | ||
|
698fb20c12 | ||
|
c6888472dc | ||
ab17e0a9f3 | |||
b99b62a211 | |||
1dcf14289d | |||
5732fde4d9 | |||
440dba4372 | |||
2bee3ac33b | |||
2189d15e5b | |||
66cf9ff70a | |||
|
8e69801e24 | ||
b5860a4283 | |||
aece5e67b7 | |||
9395312079 | |||
|
8a7eb57c24 | ||
12af0f462f | |||
db3dcc6fdc | |||
9d4973829b | |||
|
7bdb1abbe6 | ||
|
fc525c8144 | ||
cdb02c992c | |||
9f4493a0b2 | |||
397fc74a56 | |||
93f681bf7a | |||
96535366c3 | |||
3fbc8b130d | |||
dd93ac49a3 | |||
cf4cda2434 | |||
98c086abb9 | |||
|
d0fefca6f9 | ||
|
5c6258390c | ||
|
b69524b498 | ||
|
1870dc8cd9 | ||
762c1347ed | |||
|
3bf86e4ca0 | ||
29355c749e | |||
|
26816b9366 | ||
|
c239b9af83 | ||
934a26f1ef | |||
|
fbc0c28f5e | ||
|
986dc3f52c | ||
|
8660caae9b | ||
|
1a6dc72898 | ||
|
87cd4712e0 | ||
|
361655dd42 | ||
|
234411ff9a | ||
|
88122e0e76 | ||
|
bd0e69b2bb | ||
af364e7fe0 | |||
b6eecfd39d | |||
17df0ee6b3 | |||
730cb6ce67 | |||
|
2aaf4228ac | ||
4a4629763e | |||
7101ac1b4b | |||
3a6982e7b2 | |||
88978077b5 | |||
8015f560d6 | |||
e3140235de | |||
353e2e027a | |||
|
b0b93ba1e6 | ||
|
1d156e3854 | ||
|
b0af949480 | ||
|
09535e294a | ||
dfa9cb57aa | |||
5d64dfd4dc | |||
dc1bc741b4 | |||
34e539cdc0 | |||
|
ad7dfb0181 | ||
|
c4dd28e252 | ||
147972273a | |||
b15c406924 | |||
|
d4dccd17ae | ||
18002bc837 | |||
8184c8021a | |||
d0ad8022d9 | |||
e8600447ed | |||
f587008d61 | |||
b762760e96 | |||
|
36e4ab00da | ||
|
1ef3591e17 | ||
|
bebdc851c9 | ||
|
0f8372c0fb | ||
4819bd5608 | |||
|
5e78479371 | ||
|
d3a00014c9 | ||
3f0b3d7361 | |||
|
2d5a50e80f | ||
|
2080534744 | ||
|
dbf231865d | ||
e007006daf | |||
|
14f2d190e5 | ||
53d449d5d7 | |||
fd2291c004 | |||
82faea7f85 | |||
ae39ddf031 | |||
3301e0ba50 | |||
|
ed72b096b9 | ||
|
c323ab5275 | ||
725e46da4a | |||
abadc79756 | |||
|
0a27432b65 | ||
|
adeba99d38 | ||
|
7f97279c48 | ||
dd0418af18 | |||
995e56677a | |||
2a3b475b99 | |||
6b4eb79237 | |||
|
164a9c29fe | ||
|
f566af0c45 | ||
|
8c7c1f204c | ||
|
1711eb98b4 | ||
|
91926b2b1c | ||
|
9a343cfe8b | ||
4981eb38fb | |||
c19dfdae64 | |||
|
a496d8af65 | ||
bb4da4da02 | |||
8ee6f852cb | |||
|
aace1bd71e | ||
|
8daeba450c | ||
|
d28e5245d2 | ||
1c3ed864e7 | |||
49892690ff | |||
|
bc523abd62 | ||
|
3da47352be | ||
c1f48d6a0e | |||
95cd37827d | |||
|
780e7f9497 | ||
0acd0bba3a | |||
51b410ce01 | |||
|
7bc560b2ec | ||
221db1bd1b | |||
52ac4f70e1 | |||
5e9ffde0b1 | |||
410edc1df3 | |||
71a0dd4cc9 | |||
9c6673495f | |||
992b3ca95b | |||
|
492e8ad655 | ||
|
1d8183fe5f | ||
ddefc06fbc | |||
84c700eecb | |||
9c3776d034 | |||
|
ded7650a60 | ||
4896e7d096 | |||
cabbbf0afd | |||
0cc49e6ca9 | |||
9b7be1afb6 | |||
|
3cd8ded9d7 | ||
ce8622b9cd | |||
2aa6879a44 | |||
ad77c3d2d5 | |||
3e5d5abd53 | |||
c5443b6e82 | |||
313f315896 | |||
7bb546c7c2 | |||
cc2c7db9fe | |||
6fa9dc599c | |||
e645386842 | |||
048271f59b | |||
88bd368660 | |||
0e3f03836a | |||
335ebadbf8 | |||
|
464ea862b1 | ||
|
c34fffc2c4 | ||
81d4922740 | |||
|
4fcbc3749c | ||
233aa34d54 | |||
37b438a77f | |||
|
1de22f0f10 | ||
|
e9e1497830 | ||
6f05d28c56 | |||
|
e336e5bcc0 | ||
bdb95db5be | |||
|
76cd6ac6bc | ||
|
97d2584a3b | ||
ce874e183b | |||
089aefc7af | |||
fbd9adb135 | |||
5b23595710 | |||
c76e72b747 | |||
dee5c302a8 | |||
942e75fa28 | |||
ad44b7da9c | |||
|
4acd3c5bd2 | ||
1a0f70450c | |||
701abfa548 | |||
7938e8f5c8 | |||
0fccb22cd7 | |||
e4d5614f3f | |||
|
f190b272b9 | ||
|
b6411aba7a | ||
9a004c4535 | |||
78603e7e31 | |||
|
1acb51105a | ||
|
1e75e6591a | ||
90ae426e24 | |||
|
e31f74c3ad | ||
dc4ac6345c | |||
f0ce7b0e0f | |||
c24a619594 | |||
25e61b276a | |||
58b2dff0cd | |||
d14bc121c0 | |||
|
9ddc6659ec | ||
|
d2074cb669 | ||
|
6d8e1a7ab5 | ||
678e1fa927 | |||
705e74f4b9 | |||
|
f5211aab2a | ||
cee72065e9 | |||
04cb1e0161 | |||
35e6aa0d94 | |||
19db9dea30 | |||
|
49bd28e2d4 | ||
8ad4378960 | |||
a85873d294 | |||
5dd0efb280 | |||
2d00b1d0fb | |||
ff7e0c72f9 | |||
ae78b3473f | |||
|
75fe38ab08 | ||
52b221e3c9 | |||
abeefc95bb | |||
b2d2553305 | |||
ced3cbde73 | |||
ceb18b528a | |||
28faf170e9 | |||
494fcfdb8f | |||
|
1982fd630d | ||
|
cafa164f60 | ||
|
4915c31b03 | ||
|
8d1ec88195 | ||
be2d9f0427 | |||
e605d58888 | |||
1ed63e99d9 | |||
1ed7c59491 | |||
|
aaa4361158 | ||
|
5ada39f39c | ||
|
6e4599411b | ||
|
80943a65e6 | ||
|
c066915b8e | ||
|
d1342afe93 | ||
571d0a6500 | |||
617d636432 | |||
978a86cdaa | |||
eb6df560d8 | |||
|
c335069310 | ||
0ff9af5ba5 | |||
cd01c7ebd7 | |||
|
edb2e40383 | ||
ceaa485f06 | |||
|
70aa9531ef | ||
|
394c8e8037 | ||
255068f52d | |||
|
d8005f455c | ||
036c76798b | |||
edfd10262f | |||
afea72133a | |||
76fec39670 | |||
|
01cc8654e2 | ||
|
f5f7325ce3 | ||
|
a49172877d | ||
|
db02e2b29d | ||
|
b364f4a1be | ||
227f397d5e | |||
59da2976ab | |||
b6a6d52a92 | |||
db0d213710 | |||
5d0e7663b8 | |||
3f00140f5d | |||
5e5063cbdd | |||
bbcb782b33 | |||
f81a7ad9ab | |||
4b6a762a56 | |||
|
55ce7b1339 | ||
|
cac4abbe1b | ||
|
44897067ea | ||
|
981efde3ff | ||
|
30a2a6662f | ||
f93f2fe03c | |||
662efbcb68 | |||
2eac037408 | |||
fd96dfdb5e | |||
|
020cb77613 | ||
|
cbed6547e2 | ||
|
cd0f1a8eb2 | ||
|
6fbfb69ee4 | ||
ca7d2feedb | |||
613b462662 | |||
ef35971dfb | |||
9bc6698f58 | |||
d2f015d5d0 | |||
a67f46bec5 | |||
|
1a421e96d7 | ||
52810aa626 | |||
0a28ffb9c4 | |||
dca38d10eb | |||
8940ce7dbb | |||
2ff7acf207 | |||
87202b4915 | |||
b03a2d7995 | |||
f0a223f337 | |||
|
d23a596c3d | ||
3342a6a910 | |||
598e23622e | |||
|
03758a4f92 | ||
|
77a2a5eb01 | ||
|
845482a623 | ||
|
57db25e4a6 | ||
|
8f781c4757 | ||
09f892a7fa | |||
|
3b6b2c274d | ||
|
7fdd41697e | ||
aaa850c6c9 | |||
2a206e1b4d | |||
c4d54fc427 | |||
a9b180bf6f | |||
a9de6b6d00 | |||
1e30fcf235 | |||
|
56741fcea5 | ||
|
3030d24f74 | ||
|
f2508753b1 | ||
|
5222f0a8a7 | ||
2d4c41d2be | |||
ef8118f40f | |||
f69f044aaa | |||
|
9f36fd50b4 | ||
|
fc15276c10 | ||
|
e440357bec | ||
|
78cb306c07 | ||
e04d014a3a | |||
2f5e191b2d | |||
2c0928efe0 | |||
7d6985647f | |||
70f15ffd6f | |||
da14612049 | |||
|
cc0ae6343c | ||
2f32d3e6df | |||
|
073ab0aac0 | ||
d6fdfe0b6d | |||
5ccf81349e | |||
|
29037b4995 | ||
|
ef62f4698a | ||
|
563a66b053 | ||
|
602993ac76 | ||
|
47548905ce | ||
|
195bf022bb | ||
|
5aa2a682ff | ||
|
af2141417a | ||
|
2640efb3f0 | ||
|
f1d01f4fa0 | ||
|
d71897620c | ||
52983907c4 | |||
23688f17bb | |||
abe7a41f58 | |||
|
22a33929e2 | ||
ac56b57faa | |||
|
e5d3e3a9c3 | ||
ea0881f87e | |||
1b7a4ac090 | |||
bb3e29e5c4 | |||
8f745b80d3 | |||
|
7556f8615f | ||
|
a7c1c472fe | ||
|
9ad0a8825a | ||
|
e06d9a620c | ||
f0357bc988 | |||
85fd1d6d04 | |||
|
82dcaa4545 | ||
c72acfd282 | |||
|
a5454fe82d | ||
|
b037ff922c | ||
ff2f70b1de | |||
|
74655de618 | ||
f3dbfcd37b | |||
f3119b5b3a | |||
60288b2d06 | |||
|
1b9f2fa5f7 | ||
|
f03161b7f9 | ||
|
4b124b9381 | ||
d63eb7c8f5 | |||
|
4b30b6d764 | ||
cb77339370 | |||
8952cf659b | |||
|
9c7c7b06df | ||
|
8d24659892 | ||
7a9a973c89 | |||
ea5276ad7f | |||
181374d2cc | |||
|
80aef61aed | ||
|
8e96507cb1 | ||
|
4d82e23861 | ||
|
b0dd8ec729 | ||
debbd316c2 | |||
1377238950 | |||
d271ae67aa | |||
|
961d65c0ee | ||
|
50e6d81d0b | ||
|
85c07e7154 | ||
d222c60cef | |||
|
5b719a03bf | ||
db09730d5f | |||
|
7c35fc546b | ||
|
10a946cab3 | ||
20f9bde88f | |||
558644b8b3 | |||
|
3663cb5ad5 | ||
75251f6e78 | |||
|
e7b9ab1b3a | ||
3f34e5dadf | |||
|
abdbd50f5e | ||
32d43b85b9 | |||
|
8fe8836bc2 | ||
|
1b351e08aa | ||
d720993141 | |||
c9338027f2 | |||
|
e7b7b0dee3 | ||
cd5f500d11 | |||
1271c3e7cd | |||
118f197961 | |||
|
2f01e15cae | ||
cd4e0ab3c2 | |||
|
68e9755e59 | ||
|
60a1bbeb70 | ||
|
4575cd4e58 | ||
|
b407e6fffe | ||
|
edf1aed369 | ||
|
fd28f1730e | ||
|
ab174dc678 | ||
|
c4e25c8d9c | ||
|
b6be84c909 | ||
8fda7d00d5 | |||
382325ca47 | |||
|
f4565d0603 | ||
aaa536b454 | |||
6122fb9f73 | |||
cf13f11107 | |||
b9d16d7cd0 | |||
d03c435563 | |||
|
cb73a7ae0e | ||
|
6a5e82ba3b | ||
|
3050183ea9 | ||
|
94b4c0b2de | ||
|
9ce4c26c75 | ||
|
ba348e5af4 | ||
bf69d595b4 | |||
ce8e22ae3b | |||
|
3e6508f484 | ||
6780855b1f | |||
de63ff6d2a | |||
fa0b0b68ca | |||
|
c51c5b3c44 | ||
bca62cbf0f | |||
a850b287b2 | |||
|
9530dba2c2 | ||
|
f8789689f8 | ||
|
cc438980d1 | ||
|
00870dfb5e | ||
1bd7218a7e | |||
e95992b704 | |||
8ca9025311 | |||
69df9cecca | |||
b1b755713e | |||
4822a53f06 | |||
aab68f0baf | |||
|
2f54532be0 | ||
|
1f1ea68f45 | ||
|
f074e23c80 | ||
|
e0b2789840 | ||
|
284f00249a | ||
|
8aa9cc11eb | ||
916592944a | |||
|
0350f4bbeb | ||
1710844a1b | |||
|
08b953c0aa | ||
3f87b55a52 | |||
a6ec9dc666 | |||
9e1e12f6ce | |||
27cb9c433d | |||
d22152eefd | |||
|
96b73cca51 | ||
|
605ded9c73 | ||
8d49dc958e | |||
c345489923 | |||
db8753631c | |||
2e886ce4e6 | |||
|
52fcda96e1 | ||
|
fee0a0c867 | ||
360e7aec42 | |||
42c5c9fd65 | |||
1c0cc78f3f | |||
dbeb8d8da4 | |||
cd007febef | |||
a074564458 | |||
fb2b8994c7 | |||
|
95f59a0272 | ||
5c03e9e9ee | |||
1d10b19176 | |||
e0aaa3b162 | |||
c239a5f0e5 | |||
|
0d53c8dd0e | ||
e8fe8d832d | |||
|
e41117c878 | ||
|
6d70067b1a | ||
|
587731583f | ||
|
09a05f72e3 | ||
|
afb7e055e1 | ||
|
92b0536520 | ||
|
c0b11d4fa3 | ||
eded51aab6 | |||
15ea264fcf | |||
80bca8610e | |||
c10a05cb68 | |||
47a66df802 | |||
97e088dcbf | |||
|
3aff621478 | ||
|
3d746fc99a | ||
|
f3e3847e0a | ||
|
22abcb79e4 | ||
|
05ae51291c | ||
|
4f40fa1ad8 | ||
|
0dedbc7256 | ||
|
6ece596135 | ||
|
f7dfe6abb8 | ||
|
e8e5d409f9 | ||
|
18a38b0b11 | ||
|
6fa358c093 | ||
|
97a42011bb | ||
|
81142d3ffe | ||
|
a106f1bcf9 | ||
|
f5cd97e07d | ||
|
a28788488f | ||
15af11da98 | |||
be6e95e564 | |||
|
f12a637cdb | ||
|
dcd664e61b | ||
|
485feecf10 | ||
|
5c8e2b5bae | ||
|
9cf58a74d1 | ||
82419a08c2 | |||
997e373bf9 | |||
effd08c98a | |||
c2f7db9f76 | |||
|
4a43dcc373 | ||
|
309620ebbc | ||
|
ace5b71536 | ||
022fd31b66 | |||
c1b3711090 | |||
d1f7363779 | |||
0112d0d955 | |||
|
6672cbc253 | ||
|
7d861404ce | ||
|
c293c278bf | ||
|
a804926c35 | ||
4c1cb5999c | |||
e517de5480 | |||
bd3051e058 | |||
b6fc1959e6 | |||
fab33d678b | |||
afe3b6b2fc | |||
da2bb724e9 | |||
10acb79040 | |||
208c17dcc8 | |||
86dc50f9f0 | |||
|
cbeb56adc6 | ||
|
74dac13399 | ||
0c89de8065 | |||
|
365e93125d | ||
6df13643c3 | |||
|
52e66d4819 | ||
3928556033 | |||
7d1c6e9a40 | |||
|
3c43f5af1c | ||
|
9306050b9a | ||
|
6a428da11d | ||
|
57c8e98292 | ||
|
fae52848e3 | ||
|
6bf5e573e3 | ||
96f133750d | |||
|
a774e0a2a1 | ||
6e736e1f1e | |||
2233c2a76d | |||
e3860b8e97 | |||
00e2d546bf | |||
|
d9cac4ce8e | ||
|
d3dc5bf7c1 | ||
|
254200f8dc | ||
a9d98fd09b | |||
b94d380729 | |||
dc94e58cbf | |||
790b944031 | |||
0680dd2398 | |||
41777ad51c | |||
4c582cf1b6 | |||
43905a041b | |||
e015424a83 | |||
84ed1ecdfd | |||
27f2fd352a | |||
eb6b7bca20 | |||
5d1212b83c | |||
416344b361 | |||
11f771469f | |||
e3484de3b8 | |||
bca76f1f2f | |||
54c735841e | |||
318b8c691a | |||
be3a375cae | |||
63fbf70eaa | |||
c1dbeb43c9 | |||
ef2adcc95d | |||
1777070b46 | |||
9dfdf78697 | |||
c9dcb2662c | |||
234be6fb09 | |||
32d1f42626 | |||
1b38e33bd3 | |||
132e3534de | |||
983a45e178 | |||
96c9f801a9 | |||
040bd4361f | |||
a4d2c2ab71 | |||
cbed185040 | |||
186e261fc0 | |||
1fa7d17e35 | |||
f21e694907 | |||
b161cad982 | |||
4332828dd7 | |||
5872658f8c | |||
cb128256ed | |||
|
8e1e9a521a | ||
|
4247df4295 | ||
ceb1284f27 | |||
207caeda5b | |||
294acfe412 | |||
0782377ae3 | |||
fd6a040568 | |||
|
22c6cb1f99 | ||
|
032af0f687 | ||
|
6aa65a917f | ||
|
13760647f0 | ||
68bcc26ff6 | |||
325ed2ec3b | |||
9f50432999 | |||
bda657b638 | |||
|
6f58be9493 | ||
6dea945a3a | |||
5594bed6a8 | |||
f647f2ae6c | |||
7cb7c1f0f9 | |||
da6de03536 | |||
4ea6c4ad5d | |||
cddc23494d | |||
5a6f39dae4 | |||
|
cfa40e482a | ||
1b0212377d | |||
74c5380975 | |||
80992aa5ba | |||
de0fb631df | |||
831680d27e | |||
4cf1f080bf | |||
2fbd44c59d | |||
be84c7b977 | |||
043f484693 | |||
|
36e8ce624c | ||
|
13771d56cd | ||
46bb3064ed | |||
def3bb4729 | |||
da2229a303 | |||
2e4c725647 | |||
bd26e4e9c1 | |||
|
7bc601ad3b | ||
|
d8859001a7 | ||
0f1a8ec928 | |||
e583e45d9a | |||
5c6601cb2a | |||
a13e9fe395 | |||
|
c1421c9c44 | ||
55f179bf33 | |||
c7b861e0ba | |||
fe56da1654 | |||
|
a3a55dd195 | ||
63ca6e1e24 | |||
9bf444e93a | |||
8257b040be | |||
7c0ea6cfa7 | |||
ee32072489 | |||
f40f74b20d | |||
c1ef766125 | |||
2e882f75f2 | |||
9a9a0b9735 | |||
361cf995da | |||
686f1d57cb | |||
2deb0b3d7f | |||
3999e0485e | |||
b2a7ce834d | |||
59d22ef775 | |||
b1b9568c96 | |||
1e88b815da | |||
998b9454b2 | |||
d5b97c74f8 | |||
a8160288ae | |||
21e913192a | |||
|
16144c7be6 | ||
|
865185efad | ||
|
4148280a8e | ||
1aa30d855e | |||
f3aba6da92 | |||
c34cc46c2d | |||
52206998aa | |||
0c2905fd2d | |||
445f38eb3c | |||
847e19f22d | |||
ef7d10262a | |||
d6e2119277 | |||
|
29fc3681b9 | ||
871f09d109 | |||
d9ec95ab5b | |||
dd5cedcfea | |||
8f9bd1fef7 | |||
ce0a37cdf1 | |||
cac7011d53 | |||
5753c4feaa | |||
|
b08574fd57 | ||
|
baa402f82f | ||
|
1b132b5780 | ||
|
80653a5317 | ||
a9af247f1e | |||
e54eac6227 | |||
4f518a5d92 | |||
0a167a7bc7 | |||
7f58ea2725 | |||
|
8ec2dcc633 | ||
|
67d3595880 | ||
|
6034303b24 | ||
ed553aa9f0 | |||
d1aca27126 | |||
18d0edd4eb | |||
679aa5d073 | |||
7bc9bc962a | |||
b5c1fec845 | |||
|
bccca4e710 | ||
|
e664d7f5b9 | ||
|
879048983c | ||
|
96f6c1913f | ||
106ce6096b | |||
99e4551cf6 | |||
f5e96d9372 | |||
6fe3d43049 | |||
6fe4184f72 | |||
f15c3b4e1e | |||
9e61c3be94 | |||
f88180650d | |||
020b9b8cdd | |||
|
940dc73f28 | ||
d2e38b1062 | |||
3a4973ad68 | |||
0fb8450e56 | |||
13ca47a3b4 | |||
11acc7225e | |||
a1ad21ec56 | |||
bb1b4ee33e | |||
155b99b64e | |||
4d02e4c021 | |||
|
f971e31171 | ||
913383ca75 | |||
c22310bdaf | |||
eb72ace854 | |||
cb88510964 | |||
88b86721c6 | |||
f7c947888f | |||
2719daffb0 | |||
|
d17316508c | ||
|
9155469e8a | ||
c76b91d595 | |||
|
ade1d35463 | ||
b83aae0b57 | |||
df23bf7141 | |||
a6d9a9e1a8 | |||
e6717c86ac | |||
9bc45def17 | |||
2db6f35b7c | |||
|
f3fa6c1fc0 | ||
|
c836fa6912 | ||
|
a267443777 | ||
|
2fab9b5b9b | ||
|
38381ba287 | ||
|
ffcbb7613e | ||
|
bc8ad12b04 | ||
|
243dda543c | ||
|
d96c66ba9f | ||
66142c546b | |||
15f1920b25 | |||
dfd6629a6f | |||
21407260a4 | |||
f1692a07fc | |||
7485f1a5b4 | |||
b177cbce1d | |||
b8b2398d32 | |||
fab22e3d8a | |||
88e1251d6b | |||
e7a33f4988 | |||
2f7a4ecdb2 | |||
dfc9637230 | |||
2c22e413eb | |||
|
67d4daa7a1 | ||
|
0d08cf36f8 | ||
211ef795e9 | |||
a09c818746 | |||
8c765b25bc | |||
51eb538631 | |||
915fe79d7a | |||
|
ec087ce28a | ||
43592c32d9 | |||
5366797a4b | |||
75c6c8521b | |||
|
8d4ab4555c | ||
|
d5f62e1355 | ||
2f1cd9976d | |||
|
6d73b16716 | ||
461114c143 | |||
38cdfdf7e0 | |||
9b93d4c098 | |||
770dcbdc49 | |||
ee4f923f60 | |||
8cd68f56aa | |||
c43f06124a | |||
0c0c683986 | |||
36d0e34668 | |||
59bba148ff | |||
b60c7bbae7 | |||
07a9d84ed0 | |||
3b62f58dd2 | |||
6eaa06ab02 | |||
9721b77317 | |||
4b741c3759 | |||
59852d6899 | |||
2f47ea2228 | |||
70060c27b2 | |||
|
09d4bf1d7a | ||
9c30b37d57 | |||
|
10877fd45f | ||
22abbebd41 | |||
66134823bf | |||
72433b37c4 | |||
82941001e8 | |||
3ce0618362 | |||
92e4edf9b7 | |||
f4b5c1f27b | |||
610cce4162 | |||
|
674e221335 | ||
079ac091eb | |||
b5a5b307a0 | |||
df9135f410 | |||
8a25f0f816 | |||
33c5c21a57 | |||
d846740839 | |||
c3ac1649f2 | |||
66a2c4a2c3 | |||
e5497edd5c | |||
ce800f75ad | |||
b8aaf5c1f1 | |||
28ca7a727c | |||
d448bc6f9a | |||
|
d0fa235e84 | ||
70816a4779 | |||
c22090c602 | |||
313d023eef | |||
b2a89cd217 | |||
b92b8a9e15 | |||
92e30311ce | |||
0425e8b114 | |||
18e570b021 | |||
4abca69328 | |||
6b58080067 | |||
809cc1049e | |||
957e4a2611 | |||
01b7ac5fb9 | |||
d98c936295 | |||
4e80543edb | |||
|
a90b16a72c | ||
f0026065f5 | |||
2f4b3a4f83 | |||
697c62fb64 | |||
1e7c3841b2 | |||
|
ec23de7e33 | ||
b98fa12741 | |||
bd12070467 | |||
217a9b00b8 | |||
c0f4d5260f | |||
0d4679a37b | |||
05b961a90a | |||
d11af012fc | |||
d058db95e5 | |||
4df2031ae0 | |||
904ebf34a5 | |||
db4454b52b | |||
c18fa5a38e | |||
|
7ce845cde6 | ||
|
dc5636c6c1 | ||
217efdd706 | |||
4712ae4eb0 | |||
ac43b86b60 | |||
41a168ff29 | |||
fa8fcb0f8b | |||
5e64df220c | |||
f300c67a4d | |||
7117b5ce32 | |||
5284dc0c52 | |||
8daf72278d | |||
0199b5f169 | |||
a49bd1d42a | |||
b1b97db11a | |||
c5ced6fa5e | |||
8908c8b184 | |||
9c974fbe50 | |||
86f172076b | |||
9a85f1b25f | |||
a941c20024 | |||
|
09836bffef | ||
be6a7876b4 | |||
a32935fbe6 | |||
7ba6ee8714 | |||
fcf1c65fc1 | |||
1336b4ed60 | |||
641e4c5d96 | |||
f040dac647 | |||
26ad37a8c0 | |||
16005a0c05 | |||
5c83cbc1ac | |||
9878a58452 | |||
9d2046d5a2 | |||
19c8461397 | |||
6d5518cbd4 | |||
b9aef5891f | |||
|
abbce23b18 | ||
|
6bbd238d25 | ||
13e6c98e47 | |||
567a9aa131 | |||
fdaf0b3364 | |||
fac1cc7e1d | |||
8192f69584 | |||
676690097f | |||
a26bff6921 | |||
0b8369be23 | |||
5afaab7083 | |||
|
aa5db37bda | ||
fd76c5d9ec | |||
d6d040f5da | |||
2096bacd34 | |||
fc951d9295 | |||
e9535cd01d | |||
c92201a674 | |||
61815bce2e | |||
bb287cbd07 | |||
|
450a53d552 | ||
|
7edfccee49 | ||
17d3d2492c | |||
5e3902a3bc | |||
509005fa0c | |||
492625f6d6 | |||
ed264aba3c | |||
483d11e772 | |||
0b617377df | |||
f5b58bcdaf | |||
5706b533fd | |||
6962b9c433 | |||
edd0ef5991 | |||
d2bad5f79e | |||
61c560c12c | |||
d3bd7771d2 | |||
b7103a7e14 | |||
1b0da74b57 | |||
095ccae616 | |||
17bf6baa25 | |||
56947e7710 | |||
4fadc4d072 | |||
221e35ec38 | |||
85ea1046f0 | |||
cb49a7e790 | |||
6e5729c660 | |||
522299ea9e | |||
0ca385deaf | |||
cb7059f832 | |||
a497a568db | |||
|
d1cda51e80 | ||
c16458b728 | |||
6a7a262912 | |||
96c3621a80 | |||
9197b39ed6 | |||
32b0275257 | |||
31f835db86 | |||
9ea104b165 | |||
1e0f5cfcea | |||
ef564b0a93 | |||
0b467cfeed | |||
|
0f8be8d6db | ||
|
d7b730d8e0 | ||
|
fb21f9c22f | ||
4aec451584 | |||
a006f498b1 | |||
3c2a2a0adf | |||
76bf71162e | |||
bd09fe9556 | |||
36312e91e4 | |||
aab556bf77 | |||
9becced985 | |||
1571a4acd4 | |||
9fd95a4415 | |||
a789e4db2a | |||
5ca466117d | |||
0d369e6019 | |||
bafc2fc7ac | |||
4796f890fb | |||
945fd8331b | |||
83c7d82fce | |||
ac6dc65342 | |||
a8ab7864ae | |||
3b632c9501 | |||
0414edeade | |||
ef9ae09c2b | |||
f1ddf09cae | |||
32c85599b6 | |||
1f0439badd | |||
a03b9d8f02 | |||
da36f1dacc | |||
adde3375e5 | |||
bde422ab30 | |||
0050ae74a4 | |||
4949ab973b | |||
f76c3af557 | |||
522649d9d3 | |||
4354f868fd | |||
b3066db08b | |||
bf52bc22e4 | |||
875545f7e1 | |||
672798e711 | |||
807f33f748 | |||
f96a1eaae5 | |||
da8756ed0a | |||
47dd8acf54 | |||
ed00fe46e2 | |||
10b0667764 | |||
dd54a4f728 | |||
d690e9a73b | |||
d2cf36bf02 | |||
f85242af28 | |||
dc14719b08 | |||
|
875ed79f3f | ||
20a06ce3f2 | |||
cfe0d9c9c2 | |||
231f782260 | |||
33cce05300 | |||
390b204272 | |||
bd030470b1 | |||
|
b131c5bf94 | ||
|
2f59a8027e | ||
|
cc8144ac3c | ||
5043a52b88 | |||
05735b31c0 | |||
ed0dd3b8c0 | |||
1a7c6fbd32 | |||
8ebcc7ac02 | |||
5896a9d251 | |||
b01f4f75d6 | |||
|
74eae88e37 | ||
054dd186d6 | |||
62ab68d317 | |||
8f67a3c634 | |||
063811cb60 | |||
ef6df1339f | |||
27c07f1f84 | |||
54172bd322 | |||
f36aa1f40d | |||
8867fa1d52 | |||
18be8b10f5 | |||
6c31a1f9cc | |||
4fbf55d79e | |||
0637ccc1bc | |||
87db5ab64d | |||
62679090a3 | |||
edb7ab2638 | |||
8989aa1714 | |||
450ebf4e38 | |||
d7ff72a538 | |||
7d7beca9dc | |||
5427c4a230 | |||
a823084947 | |||
|
be0c6819ab | ||
|
3246e4719e | ||
d1ee0d7af7 | |||
d333651b9d | |||
|
bf1db428f6 | ||
4a1f744211 | |||
134e8c1c94 | |||
cd99b1bc1e | |||
|
9c510adc31 | ||
|
2ac0b4f5ab | ||
f8ea6a527d | |||
cf516fcc83 | |||
2eb681413d | |||
559ccf0264 | |||
7bb18bf73c | |||
63051717d7 | |||
5b2b433613 | |||
54762b601d | |||
5396cb2f5c | |||
ac280782b2 | |||
|
667dfb5d69 | ||
496c4e523c | |||
a67a69f95e | |||
0708a6d665 | |||
970e0b5185 | |||
d5d77f1738 | |||
e0ac4ad040 | |||
a3b6118927 | |||
4e5d7b6e84 | |||
08af66ba28 | |||
|
ab2012d12c | ||
|
b9ab38bdab | ||
|
e902ecab36 | ||
|
548c6697d0 | ||
|
6af8186e77 | ||
15e91e4b4c | |||
876d311706 | |||
858366c57b | |||
25fa97a20a | |||
34a827a270 | |||
1a9c1677d3 | |||
91ae9a9d49 | |||
6fe4cc3fd0 | |||
2a775cf960 | |||
eeef752a5c | |||
c999579c05 | |||
d14504763a | |||
6495cd2f65 | |||
43f783db42 | |||
f601d6cd03 | |||
7c274fdd35 | |||
2c2918cc9e | |||
fb35518848 | |||
8855b91d0c | |||
d8bcfd7d44 | |||
5b2383fa50 | |||
107005c474 | |||
30165ebd15 | |||
a5331bce77 | |||
0aec31328d | |||
48594537c0 | |||
b3b4b79775 | |||
fe5bd0341a | |||
ade334b251 | |||
7d602c26f8 | |||
44bbc45973 | |||
5f6f51b549 | |||
a8303940df | |||
0bf4a6a6b5 | |||
aff1ed95e9 | |||
80f9003135 | |||
5705839861 | |||
9c50d5f073 | |||
76b10d01ff | |||
85dd01ab6d | |||
|
d71145bc6e | ||
|
67d1c72a59 | ||
|
063b0e6f16 | ||
|
b5d3859adc | ||
6c6e7aa872 | |||
|
e638aeb778 | ||
|
c15ac96053 | ||
|
ee10798178 | ||
|
dd5be6d7d2 | ||
59391b2369 | |||
399be2370d | |||
93cb0892ae | |||
7680971b89 | |||
f893fb1614 | |||
565524a80b | |||
f607e98565 | |||
adba6b056f | |||
4005fab006 | |||
300828808d | |||
b713ee0ec5 | |||
ee7949a0fc | |||
898971cf15 | |||
d7f49cea9a | |||
8ed1610f48 | |||
51653015a0 | |||
c0802c7fa2 | |||
434bf35a55 | |||
1f29e91796 | |||
7cdf167e4b | |||
b854d8f3a0 | |||
f9443dfbd3 | |||
a215076358 | |||
da7429f0f6 | |||
70040e186e | |||
3d649756ec | |||
eead117d6b | |||
862321aa39 | |||
72c5bdbf9e | |||
fb32acb1ed | |||
aa94f11a2c | |||
788ecf9055 | |||
|
62199492ba | ||
|
b684260926 | ||
2dec9b5b10 | |||
883c8decde | |||
f36f44df74 | |||
08be1c97db | |||
|
f15dcc54be | ||
b18c87b9c4 | |||
|
7d577f137f | ||
59ab341a28 | |||
2062d07eb7 | |||
29822c13af | |||
022d632e40 | |||
|
ed7208d04e | ||
|
dd28e6fbeb | ||
|
9ab038d273 | ||
fd82487ab5 | |||
05648173ae | |||
1e5d93d9bb | |||
2fbcb2b23f | |||
702b55d16b | |||
cf9d287b23 | |||
9147675e60 | |||
|
d26557fac3 | ||
50600938cb | |||
35e05d250b | |||
6c28851fe8 | |||
30e1456ac0 | |||
ce8fadae52 | |||
55f91ac5dc | |||
|
0dcb935161 | ||
|
ebff00e057 | ||
b6ce26023e | |||
4b0a55144f | |||
83d17b2643 | |||
845c2b70a5 | |||
|
f9bd19ae94 | ||
|
ed07e98bff | ||
91649d21ae | |||
177c462b40 | |||
a27badefba | |||
7275e127cc | |||
0b69974e49 | |||
4fa7392205 | |||
774518e4fe | |||
fbab5db85a | |||
1d824ee293 | |||
8130535af4 | |||
f247b28262 | |||
ac4a62636b | |||
d932acad16 | |||
eaf548b5db | |||
35489a706b | |||
e09a035373 | |||
581f36d6ef |
556 changed files with 49567 additions and 39103 deletions
|
@ -1,4 +1,8 @@
|
|||
ui/node_modules
|
||||
ui/dist
|
||||
server/target
|
||||
# build folders and similar which are not needed for the docker build
|
||||
target
|
||||
docker
|
||||
api_tests
|
||||
ansible
|
||||
tests
|
||||
.git
|
||||
*.sh
|
||||
|
|
190
.drone.yml
Normal file
190
.drone.yml
Normal file
|
@ -0,0 +1,190 @@
|
|||
---
|
||||
kind: pipeline
|
||||
name: amd64
|
||||
|
||||
platform:
|
||||
os: linux
|
||||
arch: amd64
|
||||
|
||||
steps:
|
||||
|
||||
- name: chown repo
|
||||
image: ekidd/rust-musl-builder:1.50.0
|
||||
user: root
|
||||
commands:
|
||||
- chown 1000:1000 . -R
|
||||
|
||||
- name: check formatting
|
||||
image: rustdocker/rust:nightly
|
||||
commands:
|
||||
- /root/.cargo/bin/cargo fmt -- --check
|
||||
|
||||
- name: cargo clippy
|
||||
image: ekidd/rust-musl-builder:1.50.0
|
||||
environment:
|
||||
CARGO_HOME: /drone/src/.cargo
|
||||
commands:
|
||||
- whoami
|
||||
- ls -la ~/.cargo
|
||||
- mv ~/.cargo .
|
||||
- ls -la .cargo
|
||||
- cargo clippy --workspace --tests --all-targets --all-features -- -D warnings -D deprecated -D clippy::perf -D clippy::complexity -D clippy::dbg_macro
|
||||
- cargo clippy --workspace -- -D clippy::unwrap_used
|
||||
|
||||
- name: cargo test
|
||||
image: ekidd/rust-musl-builder:1.50.0
|
||||
environment:
|
||||
LEMMY_DATABASE_URL: postgres://lemmy:password@database:5432/lemmy
|
||||
RUST_BACKTRACE: 1
|
||||
RUST_TEST_THREADS: 1
|
||||
CARGO_HOME: /drone/src/.cargo
|
||||
commands:
|
||||
- sudo apt-get update
|
||||
- sudo apt-get -y install --no-install-recommends espeak postgresql-client
|
||||
- cargo test --workspace --no-fail-fast
|
||||
|
||||
- name: cargo build
|
||||
image: ekidd/rust-musl-builder:1.50.0
|
||||
environment:
|
||||
CARGO_HOME: /drone/src/.cargo
|
||||
commands:
|
||||
- cargo build
|
||||
- mv target/x86_64-unknown-linux-musl/debug/lemmy_server target/lemmy_server
|
||||
|
||||
- name: run federation tests
|
||||
image: node:15-alpine3.12
|
||||
environment:
|
||||
LEMMY_DATABASE_URL: postgres://lemmy:password@database:5432
|
||||
DO_WRITE_HOSTS_FILE: 1
|
||||
commands:
|
||||
- apk add bash curl postgresql-client
|
||||
- bash api_tests/prepare-drone-federation-test.sh
|
||||
- cd api_tests/
|
||||
- yarn
|
||||
- yarn api-test
|
||||
|
||||
- name: make release build and push to docker hub
|
||||
image: plugins/docker
|
||||
settings:
|
||||
dockerfile: docker/prod/Dockerfile
|
||||
username:
|
||||
from_secret: docker_username
|
||||
password:
|
||||
from_secret: docker_password
|
||||
repo: dessalines/lemmy
|
||||
auto_tag: true
|
||||
auto_tag_suffix: linux-amd64
|
||||
when:
|
||||
ref:
|
||||
- refs/tags/*
|
||||
|
||||
- name: push to docker manifest
|
||||
image: plugins/manifest
|
||||
settings:
|
||||
username:
|
||||
from_secret: docker_username
|
||||
password:
|
||||
from_secret: docker_password
|
||||
target: "dessalines/lemmy:${DRONE_TAG}"
|
||||
template: "dessalines/lemmy:${DRONE_TAG}-OS-ARCH"
|
||||
platforms:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
ignore_missing: true
|
||||
when:
|
||||
ref:
|
||||
- refs/tags/*
|
||||
|
||||
services:
|
||||
- name: database
|
||||
image: postgres:12-alpine
|
||||
environment:
|
||||
POSTGRES_USER: lemmy
|
||||
POSTGRES_PASSWORD: password
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
name: arm64
|
||||
|
||||
platform:
|
||||
os: linux
|
||||
arch: arm64
|
||||
|
||||
steps:
|
||||
|
||||
- name: cargo test
|
||||
image: rust:1.50-slim-buster
|
||||
environment:
|
||||
LEMMY_DATABASE_URL: postgres://lemmy:password@database:5432/lemmy
|
||||
RUST_BACKTRACE: 1
|
||||
RUST_TEST_THREADS: 1
|
||||
CARGO_HOME: /drone/src/.cargo
|
||||
commands:
|
||||
- apt-get update
|
||||
- apt-get -y install --no-install-recommends espeak postgresql-client libssl-dev pkg-config libpq-dev
|
||||
- cargo test --workspace --no-fail-fast
|
||||
- cargo build
|
||||
|
||||
# Using Debian here because there seems to be no official Alpine-based Rust docker image for ARM.
|
||||
- name: cargo build
|
||||
image: rust:1.50-slim-buster
|
||||
environment:
|
||||
CARGO_HOME: /drone/src/.cargo
|
||||
commands:
|
||||
- apt-get update
|
||||
- apt-get -y install --no-install-recommends libssl-dev pkg-config libpq-dev
|
||||
- cargo build
|
||||
- mv target/debug/lemmy_server target/lemmy_server
|
||||
|
||||
- name: run federation tests
|
||||
image: node:15-buster-slim
|
||||
environment:
|
||||
LEMMY_DATABASE_URL: postgres://lemmy:password@database:5432
|
||||
DO_WRITE_HOSTS_FILE: 1
|
||||
commands:
|
||||
- mkdir -p /usr/share/man/man1 /usr/share/man/man7
|
||||
- apt-get update
|
||||
- apt-get -y install --no-install-recommends bash curl libssl-dev pkg-config libpq-dev postgresql-client libc6-dev
|
||||
- bash api_tests/prepare-drone-federation-test.sh
|
||||
- cd api_tests/
|
||||
- yarn
|
||||
- yarn api-test
|
||||
|
||||
- name: make release build and push to docker hub
|
||||
image: plugins/docker
|
||||
settings:
|
||||
dockerfile: docker/prod/Dockerfile.arm
|
||||
username:
|
||||
from_secret: docker_username
|
||||
password:
|
||||
from_secret: docker_password
|
||||
repo: dessalines/lemmy
|
||||
auto_tag: true
|
||||
auto_tag_suffix: linux-arm64
|
||||
when:
|
||||
ref:
|
||||
- refs/tags/*
|
||||
|
||||
- name: push to docker manifest
|
||||
image: plugins/manifest
|
||||
settings:
|
||||
username:
|
||||
from_secret: docker_username
|
||||
password:
|
||||
from_secret: docker_password
|
||||
target: "dessalines/lemmy:${DRONE_TAG}"
|
||||
template: "dessalines/lemmy:${DRONE_TAG}-OS-ARCH"
|
||||
platforms:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
ignore_missing: true
|
||||
when:
|
||||
ref:
|
||||
- refs/tags/*
|
||||
|
||||
services:
|
||||
- name: database
|
||||
image: postgres:12-alpine
|
||||
environment:
|
||||
POSTGRES_USER: lemmy
|
||||
POSTGRES_PASSWORD: password
|
2
.gitattributes
vendored
2
.gitattributes
vendored
|
@ -1,2 +0,0 @@
|
|||
* linguist-vendored
|
||||
*.rs linguist-vendored=false
|
29
.github/ISSUE_TEMPLATE/BUG_REPORT.md
vendored
Normal file
29
.github/ISSUE_TEMPLATE/BUG_REPORT.md
vendored
Normal file
|
@ -0,0 +1,29 @@
|
|||
---
|
||||
name: "\U0001F41E Bug Report"
|
||||
about: Create a report to help us improve Lemmy
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
Found a bug? Please fill out the sections below. 👍
|
||||
|
||||
For front end issues, use [lemmy-ui](https://github.com/LemmyNet/lemmy-ui)
|
||||
|
||||
### Issue Summary
|
||||
|
||||
A summary of the bug.
|
||||
|
||||
|
||||
### Steps to Reproduce
|
||||
|
||||
1. (for example) I clicked login, and an endless spinner show up.
|
||||
2. I tried to install lemmy via this guide, and I'm getting this error.
|
||||
3. ...
|
||||
|
||||
### Technical details
|
||||
|
||||
* Please post your log: `sudo docker-compose logs > lemmy_log.out`.
|
||||
* What OS are you trying to install lemmy on?
|
||||
* Any browser console errors?
|
44
.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md
vendored
Normal file
44
.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md
vendored
Normal file
|
@ -0,0 +1,44 @@
|
|||
---
|
||||
name: "\U0001F680 Feature request"
|
||||
about: Suggest an idea for improving Lemmy
|
||||
title: ''
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
For front end issues, use [lemmy-ui](https://github.com/LemmyNet/lemmy-ui)
|
||||
|
||||
### Is your proposal related to a problem?
|
||||
|
||||
<!--
|
||||
Provide a clear and concise description of what the problem is.
|
||||
For example, "I'm always frustrated when..."
|
||||
-->
|
||||
|
||||
(Write your answer here.)
|
||||
|
||||
### Describe the solution you'd like
|
||||
|
||||
<!--
|
||||
Provide a clear and concise description of what you want to happen.
|
||||
-->
|
||||
|
||||
(Describe your proposed solution here.)
|
||||
|
||||
### Describe alternatives you've considered
|
||||
|
||||
<!--
|
||||
Let us know about other solutions you've tried or researched.
|
||||
-->
|
||||
|
||||
(Write your answer here.)
|
||||
|
||||
### Additional context
|
||||
|
||||
<!--
|
||||
Is there anything else you can add about the proposal?
|
||||
You might want to link to related issues here, if you haven't already.
|
||||
-->
|
||||
|
||||
(Write your answer here.)
|
10
.github/ISSUE_TEMPLATE/QUESTION.md
vendored
Normal file
10
.github/ISSUE_TEMPLATE/QUESTION.md
vendored
Normal file
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
name: "? Question"
|
||||
about: General questions about Lemmy
|
||||
title: ''
|
||||
labels: question
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
What's the question you have about lemmy?
|
18
.gitignore
vendored
18
.gitignore
vendored
|
@ -1,7 +1,21 @@
|
|||
# local ansible configuration
|
||||
ansible/inventory
|
||||
ansible/inventory_dev
|
||||
ansible/passwords/
|
||||
|
||||
# docker build files
|
||||
docker/lemmy_mine.hjson
|
||||
docker/dev/env_deploy.sh
|
||||
build/
|
||||
.idea/
|
||||
volumes
|
||||
|
||||
# ide config
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
# local build files
|
||||
target
|
||||
env_setup.sh
|
||||
query_testing/**/reports/*.json
|
||||
|
||||
# API tests
|
||||
api_tests/node_modules
|
||||
|
|
1
.rgignore
Normal file
1
.rgignore
Normal file
|
@ -0,0 +1 @@
|
|||
*.sqldump
|
5
.rustfmt.toml
Normal file
5
.rustfmt.toml
Normal file
|
@ -0,0 +1,5 @@
|
|||
tab_spaces = 2
|
||||
edition="2018"
|
||||
imports_layout="HorizontalVertical"
|
||||
imports_granularity="Crate"
|
||||
reorder_imports=true
|
35
.travis.yml
35
.travis.yml
|
@ -1,35 +0,0 @@
|
|||
language: rust
|
||||
rust:
|
||||
- stable
|
||||
matrix:
|
||||
allow_failures:
|
||||
- rust: nightly
|
||||
fast_finish: true
|
||||
cache: cargo
|
||||
before_cache:
|
||||
- rm -rfv target/debug/incremental/lemmy_server-*
|
||||
- rm -rfv target/debug/.fingerprint/lemmy_server-*
|
||||
- rm -rfv target/debug/build/lemmy_server-*
|
||||
- rm -rfv target/debug/deps/lemmy_server-*
|
||||
- rm -rfv target/debug/lemmy_server.d
|
||||
- cargo clean
|
||||
before_script:
|
||||
- psql -c "create user lemmy with password 'password' superuser;" -U postgres
|
||||
- psql -c 'create database lemmy with owner lemmy;' -U postgres
|
||||
- rustup component add clippy --toolchain stable-x86_64-unknown-linux-gnu
|
||||
before_install:
|
||||
- cd server
|
||||
script:
|
||||
# Default checks, but fail if anything is detected
|
||||
- cargo build
|
||||
- cargo clippy -- -D clippy::style -D clippy::correctness -D clippy::complexity -D clippy::perf
|
||||
- cargo install diesel_cli --no-default-features --features postgres --force
|
||||
- diesel migration run
|
||||
- cargo test
|
||||
env:
|
||||
global:
|
||||
- DATABASE_URL=postgres://lemmy:password@localhost:5432/lemmy
|
||||
- RUST_TEST_THREADS=1
|
||||
|
||||
addons:
|
||||
postgresql: "9.4"
|
|
@ -1,35 +0,0 @@
|
|||
# Code of Conduct
|
||||
|
||||
- We are committed to providing a friendly, safe and welcoming environment for all, regardless of level of experience, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, nationality, or other similar characteristic.
|
||||
- Please avoid using overtly sexual aliases or other nicknames that might detract from a friendly, safe and welcoming environment for all.
|
||||
- Please be kind and courteous. There’s no need to be mean or rude.
|
||||
- Respect that people have differences of opinion and that every design or implementation choice carries a trade-off and numerous costs. There is seldom a right answer.
|
||||
- Please keep unstructured critique to a minimum. If you have solid ideas you want to experiment with, make a fork and see how it works.
|
||||
- We will exclude you from interaction if you insult, demean or harass anyone. That is not welcome behavior. We interpret the term “harassment” as including the definition in the Citizen Code of Conduct; if you have any lack of clarity about what might be included in that concept, please read their definition. In particular, we don’t tolerate behavior that excludes people in socially marginalized groups.
|
||||
- Private harassment is also unacceptable. No matter who you are, if you feel you have been or are being harassed or made uncomfortable by a community member, please contact one of the channel ops or any of the Lemmy moderation team immediately. Whether you’re a regular contributor or a newcomer, we care about making this community a safe place for you and we’ve got your back.
|
||||
- Likewise any spamming, trolling, flaming, baiting or other attention-stealing behavior is not welcome.
|
||||
|
||||
[**Message the Moderation Team on Mastodon**](https://mastodon.social/@LemmyDev)
|
||||
|
||||
[**Email The Moderation Team**](mailto:contact@lemmy.ml)
|
||||
|
||||
## Moderation
|
||||
|
||||
These are the policies for upholding our community’s standards of conduct. If you feel that a thread needs moderation, please contact the Lemmy moderation team .
|
||||
|
||||
1. Remarks that violate the Lemmy standards of conduct, including hateful, hurtful, oppressive, or exclusionary remarks, are not allowed. (Cursing is allowed, but never targeting another user, and never in a hateful manner.)
|
||||
2. Remarks that moderators find inappropriate, whether listed in the code of conduct or not, are also not allowed.
|
||||
3. Moderators will first respond to such remarks with a warning.
|
||||
4. If the warning is unheeded, the user will be “kicked,” i.e., kicked out of the communication channel to cool off.
|
||||
5. If the user comes back and continues to make trouble, they will be banned, i.e., indefinitely excluded.
|
||||
6. Moderators may choose at their discretion to un-ban the user if it was a first offense and they offer the offended party a genuine apology.
|
||||
7. If a moderator bans someone and you think it was unjustified, please take it up with that moderator, or with a different moderator, in private. Complaints about bans in-channel are not allowed.
|
||||
8. Moderators are held to a higher standard than other community members. If a moderator creates an inappropriate situation, they should expect less leeway than others.
|
||||
|
||||
In the Lemmy community we strive to go the extra step to look out for each other. Don’t just aim to be technically unimpeachable, try to be your best self. In particular, avoid flirting with offensive or sensitive issues, particularly if they’re off-topic; this all too often leads to unnecessary fights, hurt feelings, and damaged trust; worse, it can drive people away from the community entirely.
|
||||
|
||||
And if someone takes issue with something you said or did, resist the urge to be defensive. Just stop doing what it was they complained about and apologize. Even if you feel you were misinterpreted or unfairly accused, chances are good there was something you could’ve communicated better — remember that it’s your responsibility to make others comfortable. Everyone wants to get along and we are all here first and foremost because we want to talk about cool technology. You will find that people will be eager to assume good intent and forgive as long as you earn their trust.
|
||||
|
||||
The enforcement policies listed above apply to all official Lemmy venues; including git repositories under [github.com/dessalines/lemmy](https://github.com/dessalines/lemmy) and [yerbamate.dev/dessalines/lemmy](https://yerbamate.dev/dessalines/lemmy), the [Matrix channel](https://matrix.to/#/!BZVTUuEiNmRcbFeLeI:matrix.org?via=matrix.org&via=privacytools.io&via=permaweb.io); and all instances under lemmy.ml. For other projects adopting the Rust Code of Conduct, please contact the maintainers of those projects for enforcement. If you wish to use this code of conduct for your own project, consider explicitly mentioning your moderation policy or making a copy with your own moderation policy so as to avoid confusion.
|
||||
|
||||
Adapted from the [Rust Code of Conduct](https://www.rust-lang.org/policies/code-of-conduct), which is based on the [Node.js Policy on Trolling](http://blog.izs.me/post/30036893703/policy-on-trolling) as well as the [Contributor Covenant v1.3.0](https://www.contributor-covenant.org/version/1/3/0/).
|
|
@ -1,4 +1,4 @@
|
|||
# Contributing
|
||||
|
||||
See [here](https://dev.lemmy.ml/docs/contributing.html) for contributing Instructions.
|
||||
See [here](https://join.lemmy.ml/docs/en/contributing/contributing.html) for contributing Instructions.
|
||||
|
||||
|
|
3951
Cargo.lock
generated
Normal file
3951
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
62
Cargo.toml
Normal file
62
Cargo.toml
Normal file
|
@ -0,0 +1,62 @@
|
|||
[package]
|
||||
name = "lemmy_server"
|
||||
version = "0.0.1"
|
||||
edition = "2018"
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
|
||||
[profile.dev]
|
||||
debug = 0
|
||||
|
||||
[workspace]
|
||||
members = [
|
||||
"crates/api",
|
||||
"crates/apub",
|
||||
"crates/utils",
|
||||
"crates/db_queries",
|
||||
"crates/db_schema",
|
||||
"crates/db_views",
|
||||
"crates/db_views_actor",
|
||||
"crates/db_views_actor",
|
||||
"crates/api_structs",
|
||||
"crates/websocket",
|
||||
"crates/routes"
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
lemmy_api = { path = "./crates/api" }
|
||||
lemmy_apub = { path = "./crates/apub" }
|
||||
lemmy_utils = { path = "./crates/utils" }
|
||||
lemmy_db_schema = { path = "./crates/db_schema" }
|
||||
lemmy_db_queries = { path = "./crates/db_queries" }
|
||||
lemmy_db_views = { path = "./crates/db_views" }
|
||||
lemmy_db_views_moderator = { path = "./crates/db_views_moderator" }
|
||||
lemmy_db_views_actor = { path = "./crates/db_views_actor" }
|
||||
lemmy_api_structs = { path = "crates/api_structs" }
|
||||
lemmy_websocket = { path = "./crates/websocket" }
|
||||
lemmy_routes = { path = "./crates/routes" }
|
||||
diesel = "1.4.5"
|
||||
diesel_migrations = "1.4.0"
|
||||
chrono = { version = "0.4.19", features = ["serde"] }
|
||||
serde = { version = "1.0.123", features = ["derive"] }
|
||||
actix = "0.10.0"
|
||||
actix-web = { version = "3.3.2", default-features = false, features = ["rustls"] }
|
||||
log = "0.4.14"
|
||||
env_logger = "0.8.2"
|
||||
strum = "0.20.0"
|
||||
url = { version = "2.2.1", features = ["serde"] }
|
||||
openssl = "0.10.32"
|
||||
http-signature-normalization-actix = { version = "0.4.1", default-features = false, features = ["sha-2"] }
|
||||
tokio = "0.3.6"
|
||||
anyhow = "1.0.38"
|
||||
reqwest = { version = "0.10.10", features = ["json"] }
|
||||
activitystreams = "0.7.0-alpha.10"
|
||||
actix-rt = { version = "1.1.1", default-features = false }
|
||||
serde_json = { version = "1.0.61", features = ["preserve_order"] }
|
||||
clokwerk = "0.3.4"
|
||||
|
||||
[dev-dependencies.cargo-husky]
|
||||
version = "1.5.0"
|
||||
default-features = false # Disable features which are enabled by default
|
||||
features = ["precommit-hook", "run-cargo-fmt", "run-cargo-clippy"]
|
126
README.md
126
README.md
|
@ -1,49 +1,53 @@
|
|||
<div align="center">
|
||||
|
||||
![GitHub tag (latest SemVer)](https://img.shields.io/github/tag/dessalines/lemmy.svg)
|
||||
[![Build Status](https://travis-ci.org/dessalines/lemmy.svg?branch=master)](https://travis-ci.org/dessalines/lemmy)
|
||||
[![GitHub issues](https://img.shields.io/github/issues-raw/dessalines/lemmy.svg)](https://github.com/dessalines/lemmy/issues)
|
||||
![GitHub tag (latest SemVer)](https://img.shields.io/github/tag/LemmyNet/lemmy.svg)
|
||||
[![Build Status](https://cloud.drone.io/api/badges/LemmyNet/lemmy/status.svg)](https://cloud.drone.io/LemmyNet/lemmy/)
|
||||
[![GitHub issues](https://img.shields.io/github/issues-raw/LemmyNet/lemmy.svg)](https://github.com/LemmyNet/lemmy/issues)
|
||||
[![Docker Pulls](https://img.shields.io/docker/pulls/dessalines/lemmy.svg)](https://cloud.docker.com/repository/docker/dessalines/lemmy/)
|
||||
[![License](https://img.shields.io/github/license/dessalines/lemmy.svg)](LICENSE)
|
||||
![GitHub stars](https://img.shields.io/github/stars/dessalines/lemmy?style=social)
|
||||
[![Translation status](http://weblate.yerbamate.ml/widgets/lemmy/-/lemmy/svg-badge.svg)](http://weblate.yerbamate.ml/engage/lemmy/)
|
||||
[![License](https://img.shields.io/github/license/LemmyNet/lemmy.svg)](LICENSE)
|
||||
![GitHub stars](https://img.shields.io/github/stars/LemmyNet/lemmy?style=social)
|
||||
[![Awesome Humane Tech](https://raw.githubusercontent.com/humanetech-community/awesome-humane-tech/main/humane-tech-badge.svg?sanitize=true)](https://github.com/humanetech-community/awesome-humane-tech)
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://dev.lemmy.ml/" rel="noopener">
|
||||
<img width=200px height=200px src="ui/assets/favicon.svg"></a>
|
||||
<a href="https://join.lemmy.ml/" rel="noopener">
|
||||
<img width=200px height=200px src="https://raw.githubusercontent.com/LemmyNet/lemmy-ui/main/src/assets/icons/favicon.svg"></a>
|
||||
|
||||
<h3 align="center"><a href="https://dev.lemmy.ml">Lemmy</a></h3>
|
||||
<h3 align="center"><a href="https://join.lemmy.ml">Lemmy</a></h3>
|
||||
<p align="center">
|
||||
A link aggregator / reddit clone for the fediverse.
|
||||
A link aggregator / Reddit clone for the fediverse.
|
||||
<br />
|
||||
<br />
|
||||
<a href="https://dev.lemmy.ml">View Site</a>
|
||||
<a href="https://join.lemmy.ml">Join Lemmy</a>
|
||||
·
|
||||
<a href="https://dev.lemmy.ml/docs/index.html">Documentation</a>
|
||||
<a href="https://join.lemmy.ml/docs/en/index.html">Documentation</a>
|
||||
·
|
||||
<a href="https://github.com/dessalines/lemmy/issues">Report Bug</a>
|
||||
<a href="https://github.com/LemmyNet/lemmy/issues">Report Bug</a>
|
||||
·
|
||||
<a href="https://github.com/dessalines/lemmy/issues">Request Feature</a>
|
||||
<a href="https://github.com/LemmyNet/lemmy/issues">Request Feature</a>
|
||||
·
|
||||
<a href="https://github.com/dessalines/lemmy/blob/master/RELEASES.md">Releases</a>
|
||||
<a href="https://github.com/LemmyNet/lemmy/blob/main/RELEASES.md">Releases</a>
|
||||
·
|
||||
<a href="https://join.lemmy.ml/docs/en/code_of_conduct.html">Code of Conduct</a>
|
||||
</p>
|
||||
</p>
|
||||
|
||||
## About The Project
|
||||
|
||||
Front Page|Post
|
||||
Desktop|Mobile
|
||||
---|---
|
||||
![main screen](https://i.imgur.com/kZSRcRu.png)|![chat screen](https://i.imgur.com/4XghNh6.png)
|
||||
![desktop](https://raw.githubusercontent.com/LemmyNet/joinlemmy-site/main/static/images/main_img.webp)|![mobile](https://raw.githubusercontent.com/LemmyNet/joinlemmy-site/main/static/images/mobile_pic.webp)
|
||||
|
||||
[Lemmy](https://github.com/dessalines/lemmy) is similar to sites like [Reddit](https://reddit.com), [Lobste.rs](https://lobste.rs), [Raddle](https://raddle.me), or [Hacker News](https://news.ycombinator.com/): you subscribe to forums you're interested in, post links and discussions, then vote, and comment on them. Behind the scenes, it is very different; anyone can easily run a server, and all these servers are federated (think email), and connected to the same universe, called the [Fediverse](https://en.wikipedia.org/wiki/Fediverse).
|
||||
[Lemmy](https://github.com/LemmyNet/lemmy) is similar to sites like [Reddit](https://reddit.com), [Lobste.rs](https://lobste.rs), or [Hacker News](https://news.ycombinator.com/): you subscribe to forums you're interested in, post links and discussions, then vote, and comment on them. Behind the scenes, it is very different; anyone can easily run a server, and all these servers are federated (think email), and connected to the same universe, called the [Fediverse](https://en.wikipedia.org/wiki/Fediverse).
|
||||
|
||||
For a link aggregator, this means a user registered on one server can subscribe to forums on any other server, and can have discussions with users registered elsewhere.
|
||||
|
||||
The overall goal is to create an easily self-hostable, decentralized alternative to reddit and other link aggregators, outside of their corporate control and meddling.
|
||||
The overall goal is to create an easily self-hostable, decentralized alternative to Reddit and other link aggregators, outside of their corporate control and meddling.
|
||||
|
||||
Each lemmy server can set its own moderation policy; appointing site-wide admins, and community moderators to keep out the trolls, and foster a healthy, non-toxic environment where all can feel comfortable contributing.
|
||||
Each Lemmy server can set its own moderation policy; appointing site-wide admins, and community moderators to keep out the trolls, and foster a healthy, non-toxic environment where all can feel comfortable contributing.
|
||||
|
||||
*Note: Federation is still in active development*
|
||||
*Note: The WebSocket and HTTP APIs are currently unstable*
|
||||
|
||||
### Why's it called Lemmy?
|
||||
|
||||
|
@ -64,19 +68,20 @@ Each lemmy server can set its own moderation policy; appointing site-wide admins
|
|||
|
||||
- Open source, [AGPL License](/LICENSE).
|
||||
- Self hostable, easy to deploy.
|
||||
- Comes with [Docker](#docker), [Ansible](#ansible), [Kubernetes](#kubernetes).
|
||||
- Comes with [Docker](https://join.lemmy.ml/docs/en/administration/install_docker.html) and [Ansible](https://join.lemmy.ml/docs/en/administration/install_ansible.html).
|
||||
- Clean, mobile-friendly interface.
|
||||
- Only a minimum of a username and password is required to sign up!
|
||||
- User avatar support.
|
||||
- Live-updating Comment threads.
|
||||
- Full vote scores `(+/-)` like old reddit.
|
||||
- Full vote scores `(+/-)` like old Reddit.
|
||||
- Themes, including light, dark, and solarized.
|
||||
- Emojis with autocomplete support. Start typing `:`
|
||||
- User tagging using `@`, Community tagging using `#`.
|
||||
- User tagging using `@`, Community tagging using `!`.
|
||||
- Integrated image uploading in both posts and comments.
|
||||
- A post can consist of a title and any combination of self text, a URL, or nothing else.
|
||||
- Notifications, on comment replies and when you're tagged.
|
||||
- Notifications can be sent via email.
|
||||
- Private messaging support.
|
||||
- i18n / internationalization support.
|
||||
- RSS / Atom feeds for `All`, `Subscribed`, `Inbox`, `User`, and `Community`.
|
||||
- Cross-posting support.
|
||||
|
@ -90,6 +95,7 @@ Each lemmy server can set its own moderation policy; appointing site-wide admins
|
|||
- Can transfer site and communities to others.
|
||||
- Can fully erase your data, replacing all posts and comments.
|
||||
- NSFW post / community support.
|
||||
- OEmbed support via Iframely.
|
||||
- High performance.
|
||||
- Server is written in rust.
|
||||
- Front end is `~80kB` gzipped.
|
||||
|
@ -97,16 +103,31 @@ Each lemmy server can set its own moderation policy; appointing site-wide admins
|
|||
|
||||
## Installation
|
||||
|
||||
- [Docker](https://dev.lemmy.ml/docs/administration_install_docker.html)
|
||||
- [Ansible](https://dev.lemmy.ml/docs/administration_install_ansible.html)
|
||||
- [Kubernetes](https://dev.lemmy.ml/docs/administration_install_kubernetes.html)
|
||||
- [Docker](https://join.lemmy.ml/docs/en/administration/install_docker.html)
|
||||
- [Ansible](https://join.lemmy.ml/docs/en/administration/install_ansible.html)
|
||||
|
||||
## Lemmy Projects
|
||||
|
||||
### Apps
|
||||
|
||||
- [lemmy-ui - The official web app for lemmy](https://github.com/LemmyNet/lemmy-ui)
|
||||
- [Lemmur - A mobile client for Lemmy (Android, Linux, Windows)](https://github.com/krawieck/lemmur)
|
||||
- [Remmel - A native iOS app](https://github.com/uuttff8/Lemmy-iOS)
|
||||
|
||||
### Libraries
|
||||
|
||||
- [lemmy-js-client](https://github.com/LemmyNet/lemmy-js-client)
|
||||
- [Kotlin API ( under development )](https://github.com/eiknat/lemmy-client)
|
||||
- [Dart API client ( under development )](https://github.com/krawieck/lemmy_api_client)
|
||||
|
||||
## Support / Donate
|
||||
|
||||
Lemmy is free, open-source software, meaning no advertising, monetizing, or venture capital, ever. Your donations directly support full-time development of the project.
|
||||
|
||||
- [Support on Liberapay.](https://liberapay.com/Lemmy)
|
||||
- [Support on Liberapay](https://liberapay.com/Lemmy).
|
||||
- [Support on Patreon](https://www.patreon.com/dessalines).
|
||||
- [List of Sponsors](https://dev.lemmy.ml/sponsors).
|
||||
- [Support on OpenCollective](https://opencollective.com/lemmy).
|
||||
- [List of Sponsors](https://join.lemmy.ml/sponsors).
|
||||
|
||||
### Crypto
|
||||
|
||||
|
@ -116,49 +137,24 @@ Lemmy is free, open-source software, meaning no advertising, monetizing, or vent
|
|||
|
||||
## Contributing
|
||||
|
||||
- [Contributing instructions](https://dev.lemmy.ml/docs/contributing.html)
|
||||
- [Docker Development](https://dev.lemmy.ml/docs/contributing_docker_development.html)
|
||||
- [Local Development](https://dev.lemmy.ml/docs/contributing_local_development.html)
|
||||
- [Contributing instructions](https://join.lemmy.ml/docs/en/contributing/contributing.html)
|
||||
- [Docker Development](https://join.lemmy.ml/docs/en/contributing/docker_development.html)
|
||||
- [Local Development](https://join.lemmy.ml/docs/en/contributing/local_development.html)
|
||||
|
||||
### Translations
|
||||
|
||||
If you'd like to add translations, take a look at the [English translation file](ui/src/translations/en.ts).
|
||||
|
||||
- Languages supported: Brazilian Portuguese (`pt-br`), Catalan, (`ca`), Farsi (`fa`), English (`en`), Chinese (`zh`), Dutch (`nl`), Esperanto (`eo`), Finnish (`fi`), French (`fr`), Spanish (`es`), Swedish (`sv`), German (`de`), Russian (`ru`), Italian (`it`).
|
||||
|
||||
<!-- translations -->
|
||||
|
||||
lang | done | missing
|
||||
---- | ---- | -------
|
||||
ca | 97% | cross_posted_to,old,support_on_liberapay,couldnt_get_comments,post_title_too_long,time,action
|
||||
de | 86% | cross_posted_to,create_private_message,send_secure_message,send_message,message,avatar,upload_avatar,show_avatars,old,docs,message_sent,messages,old_password,matrix_user_id,private_message_disclaimer,send_notifications_to_email,downvotes_disabled,enable_downvotes,open_registration,registration_closed,enable_nsfw,donate_to_lemmy,donate,from,logged_in,couldnt_get_comments,post_title_too_long,email_already_exists,couldnt_create_private_message,no_private_message_edit_allowed,couldnt_update_private_message,time,action
|
||||
fa | 71% | cross_post,cross_posted_to,subscribed_to_communities,trending_communities,create_private_message,send_secure_message,send_message,message,mod,mods,moderates,remove_as_mod,appoint_as_mod,modlog,stickied,ban,ban_from_site,unban,unban_from_site,banned,number_of_subscribers,subscribers,both,saved,unsubscribe,subscribe,subscribed,old,api,docs,inbox,inbox_for,message_sent,notifications_error,messages,no_email_setup,matrix_user_id,private_message_disclaimer,url,body,copy_suggested_title,community,expand_here,subscribe_to_communities,theme,sponsor_message,support_on_liberapay,general_sponsors,joined,by,to,from,landing_0,logged_in,couldnt_get_comments,community_moderator_already_exists,community_follower_already_exists,community_user_already_banned,post_title_too_long,no_slurs,admin_already_created,couldnt_create_private_message,no_private_message_edit_allowed,couldnt_update_private_message,time,action
|
||||
eo | 73% | cross_posted_to,number_of_communities,create_private_message,send_secure_message,send_message,message,preview,upload_image,avatar,upload_avatar,show_avatars,formatting_help,view_source,sticky,unsticky,archive_link,stickied,delete_account,delete_account_confirm,banned,creator,number_online,old,docs,replies,mentions,message_sent,messages,old_password,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,matrix_user_id,private_message_disclaimer,send_notifications_to_email,language,browser_default,downvotes_disabled,enable_downvotes,open_registration,registration_closed,enable_nsfw,theme,support_on_liberapay,donate_to_lemmy,donate,from,are_you_sure,yes,no,logged_in,couldnt_get_comments,post_title_too_long,email_already_exists,couldnt_create_private_message,no_private_message_edit_allowed,couldnt_update_private_message,time,action
|
||||
es | 99% | cross_posted_to,couldnt_get_comments,post_title_too_long
|
||||
fi | 97% | cross_posted_to,old,support_on_liberapay,couldnt_get_comments,post_title_too_long,time,action
|
||||
fr | 81% | cross_posted_to,create_private_message,send_secure_message,send_message,message,avatar,upload_avatar,show_avatars,archive_link,old,docs,replies,mentions,message_sent,messages,old_password,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,matrix_user_id,private_message_disclaimer,send_notifications_to_email,language,browser_default,downvotes_disabled,enable_downvotes,open_registration,registration_closed,enable_nsfw,donate_to_lemmy,donate,from,logged_in,couldnt_get_comments,post_title_too_long,email_already_exists,couldnt_create_private_message,no_private_message_edit_allowed,couldnt_update_private_message,time,action
|
||||
it | 82% | cross_posted_to,create_private_message,send_secure_message,send_message,message,avatar,upload_avatar,show_avatars,archive_link,old,docs,message_sent,messages,old_password,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,matrix_user_id,private_message_disclaimer,send_notifications_to_email,language,browser_default,downvotes_disabled,enable_downvotes,open_registration,registration_closed,enable_nsfw,donate_to_lemmy,donate,from,logged_in,couldnt_get_comments,post_title_too_long,email_already_exists,couldnt_create_private_message,no_private_message_edit_allowed,couldnt_update_private_message,time,action
|
||||
nl | 98% | cross_posted_to,couldnt_get_comments,post_title_too_long,time,action
|
||||
pt-br | 99% | couldnt_get_comments,post_title_too_long
|
||||
ru | 70% | cross_posts,cross_post,cross_posted_to,number_of_communities,create_private_message,send_secure_message,send_message,message,preview,upload_image,avatar,upload_avatar,show_avatars,formatting_help,view_source,sticky,unsticky,archive_link,stickied,delete_account,delete_account_confirm,banned,creator,number_online,old,docs,replies,mentions,message_sent,messages,old_password,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,matrix_user_id,private_message_disclaimer,send_notifications_to_email,language,browser_default,downvotes_disabled,enable_downvotes,open_registration,registration_closed,enable_nsfw,recent_comments,theme,support_on_liberapay,donate_to_lemmy,donate,monero,by,to,from,transfer_community,transfer_site,are_you_sure,yes,no,logged_in,couldnt_get_comments,post_title_too_long,email_already_exists,couldnt_create_private_message,no_private_message_edit_allowed,couldnt_update_private_message,time,action
|
||||
sv | 81% | cross_posted_to,create_private_message,send_secure_message,send_message,message,avatar,upload_avatar,show_avatars,archive_link,old,docs,replies,mentions,message_sent,messages,old_password,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,matrix_user_id,private_message_disclaimer,send_notifications_to_email,language,browser_default,downvotes_disabled,enable_downvotes,open_registration,registration_closed,enable_nsfw,support_on_liberapay,donate_to_lemmy,donate,from,logged_in,couldnt_get_comments,post_title_too_long,email_already_exists,couldnt_create_private_message,no_private_message_edit_allowed,couldnt_update_private_message,time,action
|
||||
zh | 69% | cross_posts,cross_post,cross_posted_to,users,number_of_communities,create_private_message,send_secure_message,send_message,message,preview,upload_image,avatar,upload_avatar,show_avatars,formatting_help,view_source,sticky,unsticky,archive_link,settings,stickied,delete_account,delete_account_confirm,banned,creator,number_online,old,docs,replies,mentions,message_sent,messages,old_password,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,matrix_user_id,private_message_disclaimer,send_notifications_to_email,language,browser_default,downvotes_disabled,enable_downvotes,open_registration,registration_closed,enable_nsfw,recent_comments,nsfw,show_nsfw,theme,donate_to_lemmy,donate,monero,by,to,from,transfer_community,transfer_site,are_you_sure,yes,no,logged_in,couldnt_get_comments,post_title_too_long,email_already_exists,couldnt_create_private_message,no_private_message_edit_allowed,couldnt_update_private_message,time,action
|
||||
<!-- translationsstop -->
|
||||
|
||||
If you'd like to update this report, run:
|
||||
|
||||
```bash
|
||||
cd ui
|
||||
ts-node translation_report.ts
|
||||
```
|
||||
If you want to help with translating, take a look at [Weblate](https://weblate.yerbamate.ml/projects/lemmy/). You can also help by [translating the documentation](https://github.com/LemmyNet/lemmy-docs#adding-a-new-language).
|
||||
|
||||
## Contact
|
||||
|
||||
- [Mastodon](https://mastodon.social/@LemmyDev) - [![Mastodon Follow](https://img.shields.io/mastodon/follow/810572?domain=https%3A%2F%2Fmastodon.social&style=social)](https://mastodon.social/@LemmyDev)
|
||||
- [Matrix](https://riot.im/app/#/room/#rust-reddit-fediverse:matrix.org) - [![Matrix](https://img.shields.io/matrix/rust-reddit-fediverse:matrix.org.svg?label=matrix-chat)](https://riot.im/app/#/room/#rust-reddit-fediverse:matrix.org)
|
||||
- [GitHub](https://github.com/dessalines/lemmy)
|
||||
- [Gitea](https://yerbamate.dev/dessalines/lemmy)
|
||||
- [GitLab](https://gitlab.com/dessalines/lemmy)
|
||||
- [Mastodon](https://mastodon.social/@LemmyDev)
|
||||
- [Matrix](https://matrix.to/#/#lemmy:matrix.org)
|
||||
|
||||
## Code Mirrors
|
||||
|
||||
- [GitHub](https://github.com/LemmyNet/lemmy)
|
||||
- [Gitea](https://yerbamate.ml/LemmyNet/lemmy)
|
||||
- [Codeberg](https://codeberg.org/LemmyNet/lemmy)
|
||||
|
||||
## Credits
|
||||
|
||||
|
|
313
RELEASES.md
313
RELEASES.md
|
@ -1,6 +1,313 @@
|
|||
# Lemmy v0.9.9 Release (2021-02-19)
|
||||
|
||||
## Changes
|
||||
|
||||
### Lemmy backend
|
||||
- Added an federated activity query sorting order.
|
||||
- Explicitly marking posts and comments as public.
|
||||
- Added a `NewComment` / forum sort for posts.
|
||||
- Fixed an issue with not setting correct published time for fetched posts.
|
||||
- Fixed an issue with an open docker port on lemmy-ui.
|
||||
- Using lemmy post link for RSS link.
|
||||
- Fixed reason and display name lengths to use char counts instead.
|
||||
|
||||
### Lemmy-ui
|
||||
|
||||
- Updated translations.
|
||||
- Made websocket host configurable.
|
||||
- Added some accessibility features.
|
||||
- Always showing password reset link.
|
||||
|
||||
# Lemmy v0.9.7 Release (2021-02-08)
|
||||
|
||||
## Changes
|
||||
|
||||
- Posts and comments are no longer live-sorted (meaning most content should stay in place).
|
||||
- Fixed an issue with the create post title field not expanding when copied from iframely suggestion.
|
||||
- Fixed broken federated community paging / sorting.
|
||||
- Added aria attributes for accessibility, thx to @Mitch Lillie.
|
||||
- Updated translations and added croatian.
|
||||
- No changes to lemmy back-end.
|
||||
|
||||
# Lemmy v0.9.6 Release (2021-02-05)
|
||||
|
||||
## Changes
|
||||
|
||||
- Fixed inbox_urls not being correctly set, which broke federation in `v0.9.5`. Added some logging to catch these.
|
||||
- Fixing community search not using auth.
|
||||
- Moved docs to https://join.lemmy.ml
|
||||
- Fixed an issue w/ lemmy-ui with forms being cleared out.
|
||||
|
||||
# Lemmy v0.9.4 Pre-Release (2021-02-02)
|
||||
|
||||
## Changes
|
||||
|
||||
### Lemmy
|
||||
|
||||
- Fixed a critical bug with votes and comment unlike responses not being `0` for your user.
|
||||
- Fixed a critical bug with comment creation not checking if its parent comment is in the post.
|
||||
- Serving proper activities for community outbox.
|
||||
- Added some active user counts, including `users_active_day`, `users_active_week`, `users_active_month`, `users_active_half_year` to `SiteAggregates` and `CommunityAggregates`. (Also added to lemmy-ui)
|
||||
- Made sure banned users can't follow.
|
||||
- Added `FederatedInstances` to `SiteResponse`, to show allowed and blocked instances. (Also added to lemmy-ui)
|
||||
- Added a `MostComments` sort for posts. (Also added to lemmy-ui)
|
||||
|
||||
### Lemmy-UI
|
||||
|
||||
- Added a scroll position restore to lemmy-ui.
|
||||
- Reworked the combined inbox so incoming comments don't wipe out your current form.
|
||||
- Fixed an updated bug on the user page.
|
||||
- Fixed cross-post titles and body getting clipped.
|
||||
- Fixing the post creation title height.
|
||||
- Squashed some other smaller bugs.
|
||||
|
||||
# Lemmy v0.9.0 Release (2021-01-25)
|
||||
|
||||
## Changes
|
||||
|
||||
Since our last release in October of last year, and we've had [~450](https://github.com/LemmyNet/lemmy/compare/v0.8.0...main) commits.
|
||||
|
||||
The biggest changes, as we'll outline below, are a re-work of Lemmy's database structure, a `v2` of Lemmy's API, and activitypub compliance fixes. The new re-worked DB is much faster, easier to maintain, and [now supports hierarchical rather than flat objects in the new API](https://github.com/LemmyNet/lemmy/issues/1275).
|
||||
|
||||
We've also seen the first release of [Lemmur](https://github.com/krawieck/lemmur/releases/tag/v0.1.1), an android / iOS (soon) / windows / linux client, as well as [Lemmer](https://github.com/uuttff8/Lemmy-iOS), a native iOS client. Much thanks to @krawieck, @shilangyu, and @uuttff8 for making these great clients. If you can, please contribute to their [patreon](https://www.patreon.com/lemmur) to help fund lemmur development.
|
||||
|
||||
## LemmyNet projects
|
||||
|
||||
### Lemmy Server
|
||||
|
||||
- [Moved views from SQL to Diesel](https://github.com/LemmyNet/lemmy/issues/1275). This was a spinal replacement for much of lemmy.
|
||||
- Removed all the old fast_tables and triggers, and created new aggregates tables.
|
||||
- Added a `v2` of the API to support the hierarchical objects created from the above changes.
|
||||
- Moved continuous integration to [drone](https://cloud.drone.io/LemmyNet/lemmy/), now includes formatting, clippy, and cargo build checks, unit testing, and federation testing. [Drone also deploys both amd64 and arm64 images to dockerhub.](https://hub.docker.com/r/dessalines/lemmy)
|
||||
- Split out documentation into git submodule.
|
||||
- Shortened slur filter to avoid false positives.
|
||||
- Added query performance testing and comparisons. Added indexes to make sure every query is `< 30 ms`.
|
||||
- Added compilation time testing.
|
||||
|
||||
### Federation
|
||||
|
||||
This release includes some bug fixes for federation, and some changes to get us closer to compliance with the ActivityPub standard.
|
||||
|
||||
- [Community bans now federating](https://github.com/LemmyNet/lemmy/issues/1287).
|
||||
- [Local posts sometimes got marked as remote](https://github.com/LemmyNet/lemmy/issues/1302).
|
||||
- [Creator of post/comment was not notified about new child comments](https://github.com/LemmyNet/lemmy/issues/1325).
|
||||
- [Community deletion now federated](https://github.com/LemmyNet/lemmy/issues/1256).
|
||||
|
||||
None of these are breaking changes, so federation between 0.9.0 and 0.8.11 will work without problems.
|
||||
|
||||
### Lemmy javascript / typescript client
|
||||
|
||||
- Updated the [lemmy-js-client](https://github.com/LemmyNet/lemmy-js-client) to use the new `v2` API. Our API docs now reference this project's files, to show what the http / websocket forms and responses should look like.
|
||||
- Drone now handles publishing its [npm packages.](https://www.npmjs.com/package/lemmy-js-client)
|
||||
|
||||
### Lemmy-UI
|
||||
|
||||
- Updated it to use the `v2` API via `lemmy-js-client`, required changing nearly every component.
|
||||
- Added a live comment count.
|
||||
- Added drone deploying, and builds for ARM.
|
||||
- Fixed community link wrapping.
|
||||
- Various other bug fixes.
|
||||
|
||||
|
||||
### Lemmy Docs
|
||||
|
||||
- We moved documentation into a separate git repository, and support translation for the docs now!
|
||||
- Moved our code of conduct into the documentation.
|
||||
|
||||
## Upgrading
|
||||
|
||||
If you'd like to make a DB backup before upgrading, follow [this guide](https://join.lemmy.ml/docs/en/administration/backup_and_restore.html).
|
||||
|
||||
- [Upgrade with manual Docker installation](https://join.lemmy.ml/docs/en/administration/install_docker.html#updating)
|
||||
- [Upgrade with Ansible installation](https://join.lemmy.ml/docs/en/administration/install_ansible.html)
|
||||
|
||||
# Lemmy v0.8.0 Release (2020-10-16)
|
||||
|
||||
## Changes
|
||||
|
||||
We've been working at warp speed since our `v0.7.0` release in June, adding over [870 commits](https://github.com/LemmyNet/lemmy/compare/v0.7.0...main) since then. :sweat:
|
||||
|
||||
Here are some of the bigger changes:
|
||||
|
||||
### LemmyNet projects
|
||||
|
||||
- Created [LemmyNet](https://github.com/LemmyNet), where all lemmy-related projects live.
|
||||
- Split out the frontend into a separete repository, [lemmy-ui](https://github.com/LemmyNet/lemmy-ui)
|
||||
- Created a [lemmy-js-client](https://github.com/LemmyNet/lemmy-js-client), for any js / typescript developers.
|
||||
- Split out i18n [lemmy-translations](https://github.com/LemmyNet/lemmy-translations), that any app or site developers can import and use. Lemmy currently supports [~30 languages!](https://weblate.yerbamate.ml/projects/lemmy/lemmy/)
|
||||
|
||||
### Lemmy Server
|
||||
|
||||
#### Federation
|
||||
|
||||
- The first **federation public beta release**, woohoo :fireworks:
|
||||
- All Lemmy functionality now works over ActivityPub (except turning remote users into mods/admins)
|
||||
- Instance allowlist and blocklist
|
||||
- Documentation for [admins](https://join.lemmy.ml/docs/administration_federation.html) and [devs](https://join.lemmy.ml/docs/contributing_federation_overview.html) on how federation works
|
||||
- Upgraded to newest versions of @asonix activitypub libraries
|
||||
- Full local federation setup for manual testing
|
||||
- Automated testing for nearly every federation action
|
||||
- Many additional security checks
|
||||
- Lots and lots of refactoring
|
||||
- Asynchronous sending of outgoing activities
|
||||
|
||||
### User Interface
|
||||
|
||||
- Separated the UI from the server code, in [lemmy-ui](https://github.com/LemmyNet/lemmy-ui).
|
||||
- The UI can now read with javascript disabled!
|
||||
- It's now a fully isomorphic application using [inferno-isomorphic](https://infernojs.org/docs/guides/isomorphic). This means that page loads are now much faster, as the server does the work.
|
||||
- The UI now also supports open-graph and twitter cards! Linking to lemmy posts (from whatever platform you use) looks pretty now: ![](https://i.imgur.com/6TZ2v7s.png)
|
||||
- Improved the search page ( more features incoming ).
|
||||
- The default view is now `Local`, instead of `All`, since all would show all federated posts.
|
||||
- User settings are now shared across browsers ( a page refresh will pick up changes ).
|
||||
- A much leaner mobile view.
|
||||
|
||||
#### Backend
|
||||
|
||||
- Re-organized the rust codebase into separate workspaces for backend and frontend.
|
||||
- Removed materialized views, making the database **a lot faster**.
|
||||
- New post sorts `Active` (previously called hot), and `Hot`. Active shows posts with recent comments, hot shows highly ranked posts.
|
||||
- New sort for `Local` ( meaning from local communities).
|
||||
- Customizeable site, user, and community icons and banners.
|
||||
- Added user preferred names / display names, bios, and cakedays.
|
||||
- Visual / Audio captchas through the lemmy API.
|
||||
- Lots of API field verifications.
|
||||
- Upgraded to pictrs-v2 ( thanks to @asonix )
|
||||
- Wayyy too many bugfixes to count.
|
||||
|
||||
## Contributors
|
||||
|
||||
We'd also like to thank both the [NLnet foundation](https://nlnet.nl/) for their support in allowing us to work full-time on Lemmy ( as well as their support for [other important open-source projects](https://nlnet.nl/project/current.html) ), [those who sponsor us](https://lemmy.ml/sponsors), and those who [help translate Lemmy](https://weblate.yerbamate.ml/projects/lemmy/). Every little bit does help. We remain committed to never allowing advertisements, monetizing, or venture-capital in Lemmy; software should be communal, and should benefit humanity, not a small group of company owners.
|
||||
|
||||
## Upgrading
|
||||
|
||||
- [with manual Docker installation](https://join.lemmy.ml/docs/administration_install_docker.html#updating)
|
||||
- [with Ansible installation](https://join.lemmy.ml/docs/administration_install_ansible.html)
|
||||
|
||||
## Testing Federation
|
||||
|
||||
Federation is finally ready in Lemmy, pending possible bugs or other issues. So for now we suggest to enable federation only on test servers, or try it on our own test servers ( [enterprise](https://enterprise.lemmy.ml/), [ds9](https://ds9.lemmy.ml/), [voyager](https://voyager.lemmy.ml/) ).
|
||||
|
||||
If everything goes well, after a few weeks we will enable federation on lemmy.ml, at first with a limited number of trusted instances. We will also likely change the domain to https://lemmy.ml . Keep in mind that changing domains after turning on federation will break things.
|
||||
|
||||
To enable on your instance, edit your [lemmy.hjson](https://github.com/LemmyNet/lemmy/blob/main/config/defaults.hjson#L60) federation section to `enabled: true`, and restart.
|
||||
|
||||
### Connecting to another server
|
||||
|
||||
The server https://ds9.lemmy.ml has open federation, so after either adding it to the `allowed_instances` list in your `config.hjson`, or if you have open federation, you don't need to add it explicitly.
|
||||
|
||||
To federate / connect with a server, type in `!community_name@server.tld`, in your server's search box [like so](https://voyager.lemmy.ml/search/q/!main%40ds9.lemmy.ml/type/All/sort/TopAll/page/1).
|
||||
|
||||
To connect with the `main` community on ds9, the search is `!main@ds9.lemmy.ml`.
|
||||
|
||||
You can then click the community, and you will see a local version of the community, which you can subscribe to. New posts and comments from `!main@ds9.lemmy.ml` will now show up on your front page, or `/c/All`
|
||||
|
||||
# Lemmy v0.7.40 Pre-Release (2020-08-05)
|
||||
|
||||
We've [added a lot](https://github.com/LemmyNet/lemmy/compare/v0.7.40...v0.7.0) in this pre-release:
|
||||
|
||||
- New post sorts `Active` (previously called hot), and `Hot`. Active shows posts with recent comments, hot shows highly ranked posts.
|
||||
- Customizeable site icon and banner, user icon and banner, and community icon and banner.
|
||||
- Added user preferred names / display names, bios, and cakedays.
|
||||
- User settings are now shared across browsers (a page refresh will pick up changes).
|
||||
- Visual / Audio captchas through the lemmy API.
|
||||
- Lots of UI prettiness.
|
||||
- Lots of bug fixes.
|
||||
- Lots of additional translations.
|
||||
- Lots of federation prepping / additions / refactors.
|
||||
|
||||
This release removes the need for you to have a pictrs nginx route (the requests are now routed through lemmy directly). Follow the upgrade instructions below to replace your nginx with the new one.
|
||||
|
||||
## Upgrading
|
||||
|
||||
**With Ansible:**
|
||||
|
||||
```
|
||||
# run these commands locally
|
||||
git pull
|
||||
cd ansible
|
||||
ansible-playbook lemmy.yml
|
||||
```
|
||||
|
||||
**With manual Docker installation:**
|
||||
```
|
||||
# run these commands on your server
|
||||
cd /lemmy
|
||||
wget https://raw.githubusercontent.com/LemmyNet/lemmy/master/ansible/templates/nginx.conf
|
||||
# Replace the {{ vars }}
|
||||
sudo mv nginx.conf /etc/nginx/sites-enabled/lemmy.conf
|
||||
sudo nginx -s reload
|
||||
wget https://raw.githubusercontent.com/LemmyNet/lemmy/master/docker/prod/docker-compose.yml
|
||||
sudo docker-compose up -d
|
||||
```
|
||||
|
||||
|
||||
# Lemmy v0.7.0 Release (2020-06-23)
|
||||
|
||||
This release replaces [pictshare](https://github.com/HaschekSolutions/pictshare)
|
||||
with [pict-rs](https://git.asonix.dog/asonix/pict-rs), which improves performance
|
||||
and security.
|
||||
|
||||
Overall, since our last major release in January (v0.6.0), we have closed over
|
||||
[100 issues!](https://github.com/LemmyNet/lemmy/milestone/16?closed=1)
|
||||
|
||||
- Site-wide list of recent comments
|
||||
- Reconnecting websockets
|
||||
- Many more themes, including a default light one.
|
||||
- Expandable embeds for post links (and thumbnails), from
|
||||
[iframely](https://github.com/itteco/iframely)
|
||||
- Better icons
|
||||
- Emoji autocomplete to post and message bodies, and an Emoji Picker
|
||||
- Post body now searchable
|
||||
- Community title and description is now searchable
|
||||
- Simplified cross-posts
|
||||
- Better documentation
|
||||
- LOTS more languages
|
||||
- Lots of bugs squashed
|
||||
- And more ...
|
||||
|
||||
## Upgrading
|
||||
|
||||
Before starting the upgrade, make sure that you have a working backup of your
|
||||
database and image files. See our
|
||||
[documentation](https://join.lemmy.ml/docs/administration_backup_and_restore.html)
|
||||
for backup instructions.
|
||||
|
||||
**With Ansible:**
|
||||
|
||||
```
|
||||
# deploy with ansible from your local lemmy git repo
|
||||
git pull
|
||||
cd ansible
|
||||
ansible-playbook lemmy.yml
|
||||
# connect via ssh to run the migration script
|
||||
ssh your-server
|
||||
cd /lemmy/
|
||||
wget https://raw.githubusercontent.com/LemmyNet/lemmy/master/docker/prod/migrate-pictshare-to-pictrs.bash
|
||||
chmod +x migrate-pictshare-to-pictrs.bash
|
||||
sudo ./migrate-pictshare-to-pictrs.bash
|
||||
```
|
||||
|
||||
**With manual Docker installation:**
|
||||
```
|
||||
# run these commands on your server
|
||||
cd /lemmy
|
||||
wget https://raw.githubusercontent.com/LemmyNet/lemmy/master/ansible/templates/nginx.conf
|
||||
# Replace the {{ vars }}
|
||||
sudo mv nginx.conf /etc/nginx/sites-enabled/lemmy.conf
|
||||
sudo nginx -s reload
|
||||
wget https://raw.githubusercontent.com/LemmyNet/lemmy/master/docker/prod/docker-compose.yml
|
||||
wget https://raw.githubusercontent.com/LemmyNet/lemmy/master/docker/prod/migrate-pictshare-to-pictrs.bash
|
||||
chmod +x migrate-pictshare-to-pictrs.bash
|
||||
sudo bash migrate-pictshare-to-pictrs.bash
|
||||
```
|
||||
|
||||
**Note:** After upgrading, all users need to reload the page, then logout and
|
||||
login again, so that images are loaded correctly.
|
||||
|
||||
# Lemmy v0.6.0 Release (2020-01-16)
|
||||
|
||||
`v0.6.0` is here, and we've closed [41 issues!](https://github.com/dessalines/lemmy/milestone/15?closed=1)
|
||||
`v0.6.0` is here, and we've closed [41 issues!](https://github.com/LemmyNet/lemmy/milestone/15?closed=1)
|
||||
|
||||
This is the biggest release by far:
|
||||
|
||||
|
@ -10,7 +317,7 @@ This is the biggest release by far:
|
|||
- Can set a custom language.
|
||||
- Lemmy-wide settings to disable downvotes, and close registration.
|
||||
- A better documentation system, hosted in lemmy itself.
|
||||
- [Huge DB performance gains](https://github.com/dessalines/lemmy/issues/411) (everthing down to < `30ms`) by using materialized views.
|
||||
- [Huge DB performance gains](https://github.com/LemmyNet/lemmy/issues/411) (everthing down to < `30ms`) by using materialized views.
|
||||
- Fixed major issue with similar post URL and title searching.
|
||||
- Upgraded to Actix `2.0`
|
||||
- Faster comment / post voting.
|
||||
|
@ -19,4 +326,4 @@ This is the biggest release by far:
|
|||
|
||||
Another major announcement is that Lemmy now has another lead developer besides me, [@felix@radical.town](https://radical.town/@felix). Theyve created a better documentation system, implemented RSS feeds, simplified docker and project configs, upgraded actix, working on federation, a whole lot else.
|
||||
|
||||
https://dev.lemmy.ml
|
||||
https://lemmy.ml
|
||||
|
|
|
@ -1 +1 @@
|
|||
v0.6.17
|
||||
0.10.0-rc.7
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
[defaults]
|
||||
inventory = inventory
|
||||
interpreter_python = /usr/bin/python3
|
||||
|
||||
[ssh_connection]
|
||||
pipelining = True
|
||||
|
|
|
@ -1,6 +1,12 @@
|
|||
[lemmy]
|
||||
# define the username and hostname that you use for ssh connection, and specify the domain
|
||||
myuser@example.com domain=example.com letsencrypt_contact_email=your@email.com
|
||||
# to get started, copy this file to `inventory` and adjust the values below.
|
||||
# - `myuser@example.com`: replace with the destination you use to connect to your server via ssh
|
||||
# - `domain=example.com`: replace `example.com` with your lemmy domain
|
||||
# - `letsencrypt_contact_email=your@email.com` replace `your@email.com` with your email address,
|
||||
# to get notifications if your ssl cert expires
|
||||
# - `lemmy_base_dir=/srv/lemmy`: the location on the server where lemmy can be installed, can be any folder
|
||||
# if you are upgrading from a previous version, set this to `/lemmy`
|
||||
myuser@example.com domain=example.com letsencrypt_contact_email=your@email.com lemmy_base_dir=/srv/lemmy
|
||||
|
||||
[all:vars]
|
||||
ansible_connection=ssh
|
||||
|
|
|
@ -5,18 +5,41 @@
|
|||
# https://www.josharcher.uk/code/ansible-python-connection-failure-ubuntu-server-1604/
|
||||
gather_facts: False
|
||||
pre_tasks:
|
||||
- name: check lemmy_base_dir
|
||||
fail:
|
||||
msg: "`lemmy_base_dir` is unset. if you are upgrading from an older version, add `lemmy_base_dir=/lemmy` to your inventory file."
|
||||
when: lemmy_base_dir is not defined
|
||||
|
||||
- name: install python for Ansible
|
||||
raw: test -e /usr/bin/python || (apt -y update && apt install -y python-minimal python-setuptools)
|
||||
# python2-minimal instead of python-minimal for ubuntu 20.04 and up
|
||||
raw: test -e /usr/bin/python || (apt -y update && apt install -y python3-minimal python3-setuptools)
|
||||
args:
|
||||
executable: /bin/bash
|
||||
register: output
|
||||
changed_when: output.stdout != ""
|
||||
changed_when: output.stdout != ''
|
||||
|
||||
- setup: # gather facts
|
||||
|
||||
tasks:
|
||||
- name: install dependencies
|
||||
apt:
|
||||
pkg: ['nginx', 'docker-compose', 'docker.io', 'certbot', 'python-certbot-nginx']
|
||||
pkg:
|
||||
- 'nginx'
|
||||
- 'docker-compose'
|
||||
- 'docker.io'
|
||||
- 'certbot'
|
||||
|
||||
- name: install certbot-nginx on ubuntu < 20
|
||||
apt:
|
||||
pkg:
|
||||
- 'python-certbot-nginx'
|
||||
when: ansible_distribution == 'Ubuntu' and ansible_distribution_version is version('20.04', '<')
|
||||
|
||||
- name: install certbot-nginx on ubuntu > 20
|
||||
apt:
|
||||
pkg:
|
||||
- 'python3-certbot-nginx'
|
||||
when: ansible_distribution == 'Ubuntu' and ansible_distribution_version is version('20.04', '>=')
|
||||
|
||||
- name: request initial letsencrypt certificate
|
||||
command: certbot certonly --nginx --agree-tos -d '{{ domain }}' -m '{{ letsencrypt_contact_email }}'
|
||||
|
@ -24,24 +47,53 @@
|
|||
creates: '/etc/letsencrypt/live/{{domain}}/privkey.pem'
|
||||
|
||||
- name: create lemmy folder
|
||||
file: path={{item.path}} state=directory
|
||||
file:
|
||||
path: '{{item.path}}'
|
||||
owner: '{{item.owner}}'
|
||||
state: directory
|
||||
with_items:
|
||||
- { path: '/lemmy/' }
|
||||
- { path: '/lemmy/volumes/' }
|
||||
- path: '{{lemmy_base_dir}}'
|
||||
owner: 'root'
|
||||
- path: '{{lemmy_base_dir}}/volumes/'
|
||||
owner: 'root'
|
||||
- path: '{{lemmy_base_dir}}/volumes/pictrs/'
|
||||
owner: '991'
|
||||
|
||||
- block:
|
||||
- name: add template files
|
||||
template: src={{item.src}} dest={{item.dest}} mode={{item.mode}}
|
||||
template:
|
||||
src: '{{item.src}}'
|
||||
dest: '{{item.dest}}'
|
||||
mode: '{{item.mode}}'
|
||||
with_items:
|
||||
- { src: 'templates/docker-compose.yml', dest: '/lemmy/docker-compose.yml', mode: '0600' }
|
||||
- { src: 'templates/nginx.conf', dest: '/etc/nginx/sites-enabled/lemmy.conf', mode: '0644' }
|
||||
- src: 'templates/docker-compose.yml'
|
||||
dest: '{{lemmy_base_dir}}/docker-compose.yml'
|
||||
mode: '0600'
|
||||
- src: 'templates/nginx.conf'
|
||||
dest: '/etc/nginx/sites-enabled/lemmy.conf'
|
||||
mode: '0644'
|
||||
- src: '../docker/iframely.config.local.js'
|
||||
dest: '{{lemmy_base_dir}}/iframely.config.local.js'
|
||||
mode: '0600'
|
||||
vars:
|
||||
lemmy_docker_image: "dessalines/lemmy:{{ lookup('file', 'VERSION') }}"
|
||||
lemmy_docker_ui_image: "dessalines/lemmy-ui:{{ lookup('file', 'VERSION') }}"
|
||||
lemmy_port: "8536"
|
||||
lemmy_ui_port: "1235"
|
||||
pictshare_port: "8537"
|
||||
iframely_port: "8538"
|
||||
|
||||
- name: add config file (only during initial setup)
|
||||
template: src='templates/config.hjson' dest='/lemmy/lemmy.hjson' mode='0600' force='no' owner='1000' group='1000'
|
||||
template:
|
||||
src: 'templates/config.hjson'
|
||||
dest: '{{lemmy_base_dir}}/lemmy.hjson'
|
||||
mode: '0600'
|
||||
force: false
|
||||
owner: '1000'
|
||||
group: '1000'
|
||||
vars:
|
||||
postgres_password: "{{ lookup('password', 'passwords/{{ inventory_hostname }}/postgres chars=ascii_letters,digits') }}"
|
||||
jwt_password: "{{ lookup('password', 'passwords/{{ inventory_hostname }}/jwt chars=ascii_letters,digits') }}"
|
||||
lemmy_docker_image: "dessalines/lemmy:{{ lookup('file', 'VERSION') }}"
|
||||
|
||||
- name: enable and start docker service
|
||||
systemd:
|
||||
|
@ -51,16 +103,17 @@
|
|||
|
||||
- name: start docker-compose
|
||||
docker_compose:
|
||||
project_src: /lemmy/
|
||||
project_src: '{{lemmy_base_dir}}'
|
||||
state: present
|
||||
pull: yes
|
||||
remove_orphans: yes
|
||||
|
||||
- name: reload nginx with new config
|
||||
shell: nginx -s reload
|
||||
|
||||
- name: certbot renewal cronjob
|
||||
cron:
|
||||
special_time=daily
|
||||
name=certbot-renew-lemmy
|
||||
user=root
|
||||
job="certbot certonly --nginx -d '{{ domain }}' --deploy-hook 'docker-compose -f /peertube/docker-compose.yml exec nginx nginx -s reload'"
|
||||
special_time: daily
|
||||
name: certbot-renew-lemmy
|
||||
user: root
|
||||
job: "certbot certonly --nginx -d '{{ domain }}' --deploy-hook 'nginx -s reload'"
|
||||
|
|
|
@ -1,24 +1,34 @@
|
|||
---
|
||||
- hosts: all
|
||||
vars:
|
||||
lemmy_docker_image: "lemmy:dev"
|
||||
lemmy_docker_image: 'lemmy:dev'
|
||||
|
||||
# Install python if required
|
||||
# https://www.josharcher.uk/code/ansible-python-connection-failure-ubuntu-server-1604/
|
||||
gather_facts: False
|
||||
pre_tasks:
|
||||
- name: check lemmy_base_dir
|
||||
fail:
|
||||
msg: "`lemmy_base_dir` is unset. if you are upgrading from an older version, add `lemmy_base_dir=/lemmy` to your inventory file."
|
||||
when: lemmy_base_dir is not defined
|
||||
|
||||
- name: install python for Ansible
|
||||
raw: test -e /usr/bin/python || (apt -y update && apt install -y python-minimal python-setuptools)
|
||||
args:
|
||||
executable: /bin/bash
|
||||
register: output
|
||||
changed_when: output.stdout != ""
|
||||
changed_when: output.stdout != ''
|
||||
- setup: # gather facts
|
||||
|
||||
tasks:
|
||||
- name: install dependencies
|
||||
apt:
|
||||
pkg: ['nginx', 'docker-compose', 'docker.io', 'certbot', 'python-certbot-nginx']
|
||||
pkg:
|
||||
- 'nginx'
|
||||
- 'docker-compose'
|
||||
- 'docker.io'
|
||||
- 'certbot'
|
||||
- 'python-certbot-nginx'
|
||||
|
||||
- name: request initial letsencrypt certificate
|
||||
command: certbot certonly --nginx --agree-tos -d '{{ domain }}' -m '{{ letsencrypt_contact_email }}'
|
||||
|
@ -26,20 +36,51 @@
|
|||
creates: '/etc/letsencrypt/live/{{domain}}/privkey.pem'
|
||||
|
||||
- name: create lemmy folder
|
||||
file: path={{item.path}} state=directory
|
||||
file:
|
||||
path: '{{item.path}}'
|
||||
owner: '{{item.owner}}'
|
||||
state: directory
|
||||
with_items:
|
||||
- { path: '/lemmy/' }
|
||||
- { path: '/lemmy/volumes/' }
|
||||
- path: '{{lemmy_base_dir}}/lemmy/'
|
||||
owner: 'root'
|
||||
- path: '{{lemmy_base_dir}}/volumes/'
|
||||
owner: 'root'
|
||||
- path: '{{lemmy_base_dir}}/volumes/pictrs/'
|
||||
owner: '991'
|
||||
|
||||
- block:
|
||||
- name: add template files
|
||||
template: src={{item.src}} dest={{item.dest}} mode={{item.mode}}
|
||||
template:
|
||||
src: '{{item.src}}'
|
||||
dest: '{{item.dest}}'
|
||||
mode: '{{item.mode}}'
|
||||
with_items:
|
||||
- { src: 'templates/docker-compose.yml', dest: '/lemmy/docker-compose.yml', mode: '0600' }
|
||||
- { src: 'templates/nginx.conf', dest: '/etc/nginx/sites-enabled/lemmy.conf', mode: '0644' }
|
||||
- src: 'templates/docker-compose.yml'
|
||||
dest: '{{lemmy_base_dir}}/docker-compose.yml'
|
||||
mode: '0600'
|
||||
- src: 'templates/nginx.conf'
|
||||
dest: '/etc/nginx/sites-enabled/lemmy.conf'
|
||||
mode: '0644'
|
||||
- src: '../docker/iframely.config.local.js'
|
||||
dest: '{{lemmy_base_dir}}/iframely.config.local.js'
|
||||
mode: '0600'
|
||||
vars:
|
||||
lemmy_docker_image: "dessalines/lemmy:dev"
|
||||
lemmy_docker_ui_image: "dessalines/lemmy-ui:{{ lookup('file', 'VERSION') }}"
|
||||
lemmy_port: "8536"
|
||||
lemmy_ui_port: "1235"
|
||||
pictshare_port: "8537"
|
||||
iframely_port: "8538"
|
||||
postgres_password: "{{ lookup('password', 'passwords/{{ inventory_hostname }}/postgres chars=ascii_letters,digits') }}"
|
||||
|
||||
- name: add config file (only during initial setup)
|
||||
template: src='templates/config.hjson' dest='/lemmy/lemmy.hjson' mode='0600' force='no' owner='1000' group='1000'
|
||||
template:
|
||||
src: 'templates/config.hjson'
|
||||
dest: '{{lemmy_base_dir}}/lemmy.hjson'
|
||||
mode: '0600'
|
||||
force: false
|
||||
owner: '1000'
|
||||
group: '1000'
|
||||
vars:
|
||||
postgres_password: "{{ lookup('password', 'passwords/{{ inventory_hostname }}/postgres chars=ascii_letters,digits') }}"
|
||||
jwt_password: "{{ lookup('password', 'passwords/{{ inventory_hostname }}/jwt chars=ascii_letters,digits') }}"
|
||||
|
@ -57,22 +98,29 @@
|
|||
local_action: shell sudo docker save lemmy:dev > lemmy-dev.tar
|
||||
|
||||
- name: copy dev docker image to server
|
||||
copy: src=lemmy-dev.tar dest=/lemmy/lemmy-dev.tar
|
||||
copy:
|
||||
src: lemmy-dev.tar
|
||||
dest: '{{lemmy_base_dir}}/lemmy-dev.tar'
|
||||
|
||||
- name: import docker image
|
||||
docker_image:
|
||||
name: lemmy
|
||||
tag: dev
|
||||
load_path: /lemmy/lemmy-dev.tar
|
||||
load_path: '{{lemmy_base_dir}}/lemmy-dev.tar'
|
||||
source: load
|
||||
force_source: yes
|
||||
register: image_import
|
||||
|
||||
- name: delete remote image file
|
||||
file: path=/lemmy/lemmy-dev.tar state=absent
|
||||
file:
|
||||
path: '{{lemmy_base_dir}}/lemmy-dev.tar'
|
||||
state: absent
|
||||
|
||||
- name: delete local image file
|
||||
local_action: file path=lemmy-dev.tar state=absent
|
||||
local_action:
|
||||
module: file
|
||||
path: lemmy-dev.tar
|
||||
state: absent
|
||||
|
||||
- name: enable and start docker service
|
||||
systemd:
|
||||
|
@ -84,9 +132,10 @@
|
|||
# be a problem for testing
|
||||
- name: start docker-compose
|
||||
docker_compose:
|
||||
project_src: /lemmy/
|
||||
project_src: '{{lemmy_base_dir}}'
|
||||
state: present
|
||||
recreate: always
|
||||
remove_orphans: yes
|
||||
ignore_errors: yes
|
||||
|
||||
- name: reload nginx with new config
|
||||
|
@ -94,7 +143,7 @@
|
|||
|
||||
- name: certbot renewal cronjob
|
||||
cron:
|
||||
special_time=daily
|
||||
name=certbot-renew-lemmy
|
||||
user=root
|
||||
job="certbot certonly --nginx -d '{{ domain }}' --deploy-hook 'docker-compose -f /peertube/docker-compose.yml exec nginx nginx -s reload'"
|
||||
special_time: daily
|
||||
name: certbot-renew-lemmy
|
||||
user: root
|
||||
job: "certbot certonly --nginx -d '{{ domain }}' --deploy-hook 'nginx -s reload'"
|
||||
|
|
|
@ -1,14 +1,37 @@
|
|||
{
|
||||
# for more info about the config, check out the documentation
|
||||
# https://join.lemmy.ml/docs/en/administration/configuration.html
|
||||
|
||||
# settings related to the postgresql database
|
||||
database: {
|
||||
# password to connect to postgres
|
||||
password: "{{ postgres_password }}"
|
||||
host: "lemmy_db"
|
||||
# host where postgres is running
|
||||
host: "postgres"
|
||||
}
|
||||
# the domain name of your instance (eg "lemmy.ml")
|
||||
hostname: "{{ domain }}"
|
||||
# json web token for authorization between server and client
|
||||
jwt_secret: "{{ jwt_password }}"
|
||||
front_end_dir: "/app/dist"
|
||||
# email sending configuration
|
||||
email: {
|
||||
# hostname of the smtp server
|
||||
smtp_server: "postfix:25"
|
||||
# address to send emails from, eg "noreply@your-instance.com"
|
||||
smtp_from_address: "noreply@{{ domain }}"
|
||||
use_tls: false
|
||||
}
|
||||
# settings related to activitypub federation
|
||||
federation: {
|
||||
# whether to enable activitypub federation.
|
||||
enabled: false
|
||||
# Allows and blocks are described here:
|
||||
# https://join.lemmy.ml/docs/en/federation/administration.html#instance-allowlist-and-blocklist
|
||||
#
|
||||
# comma separated list of instances with which federation is allowed
|
||||
# Only one of these blocks should be uncommented
|
||||
# allowed_instances: ["instance1.tld","instance2.tld"]
|
||||
# comma separated list of instances which are blocked from federating
|
||||
# blocked_instances: []
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
version: '3.3'
|
||||
version: '2.2'
|
||||
|
||||
services:
|
||||
lemmy:
|
||||
|
@ -6,35 +6,58 @@ services:
|
|||
ports:
|
||||
- "127.0.0.1:8536:8536"
|
||||
restart: always
|
||||
environment:
|
||||
- RUST_LOG=error
|
||||
volumes:
|
||||
- ./lemmy.hjson:/config/config.hjson:ro
|
||||
depends_on:
|
||||
- lemmy_db
|
||||
- lemmy_pictshare
|
||||
- postgres
|
||||
- pictrs
|
||||
- iframely
|
||||
|
||||
lemmy_db:
|
||||
lemmy-ui:
|
||||
image: {{ lemmy_docker_ui_image }}
|
||||
ports:
|
||||
- "127.0.0.1:1235:1234"
|
||||
restart: always
|
||||
environment:
|
||||
- LEMMY_INTERNAL_HOST=lemmy:8536
|
||||
- LEMMY_EXTERNAL_HOST={{ domain }}
|
||||
- LEMMY_HTTPS=true
|
||||
depends_on:
|
||||
- lemmy
|
||||
|
||||
postgres:
|
||||
image: postgres:12-alpine
|
||||
environment:
|
||||
- POSTGRES_USER=lemmy
|
||||
- POSTGRES_PASSWORD={{ postgres_password }}
|
||||
- POSTGRES_DB=lemmy
|
||||
volumes:
|
||||
- lemmy_db:/var/lib/postgresql/data
|
||||
- ./volumes/postgres:/var/lib/postgresql/data
|
||||
restart: always
|
||||
|
||||
lemmy_pictshare:
|
||||
image: shtripok/pictshare:latest
|
||||
pictrs:
|
||||
image: asonix/pictrs:v0.2.6-r1
|
||||
user: 991:991
|
||||
ports:
|
||||
- "127.0.0.1:8537:80"
|
||||
- "127.0.0.1:8537:8080"
|
||||
volumes:
|
||||
- lemmy_pictshare:/usr/share/nginx/html/data
|
||||
- ./volumes/pictrs:/mnt
|
||||
restart: always
|
||||
mem_limit: 200m
|
||||
|
||||
iframely:
|
||||
image: dogbin/iframely:latest
|
||||
ports:
|
||||
- "127.0.0.1:8061:80"
|
||||
volumes:
|
||||
- ./iframely.config.local.js:/iframely/config.local.js:ro
|
||||
restart: always
|
||||
mem_limit: 200m
|
||||
|
||||
postfix:
|
||||
image: mwader/postfix-relay
|
||||
environment:
|
||||
- POSTFIX_myhostname={{ domain }}
|
||||
restart: "always"
|
||||
volumes:
|
||||
lemmy_db:
|
||||
lemmy_pictshare:
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
proxy_cache_path /var/cache/lemmy_frontend levels=1:2 keys_zone=lemmy_frontend_cache:10m max_size=100m use_temp_path=off;
|
||||
limit_req_zone $binary_remote_addr zone=lemmy_ratelimit:10m rate=1r/s;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
|
@ -36,7 +36,7 @@ server {
|
|||
# It might be nice to compress JSON, but leaving that out to protect against potential
|
||||
# compression+encryption information leak attacks like BREACH.
|
||||
gzip on;
|
||||
gzip_types text/css application/javascript;
|
||||
gzip_types text/css application/javascript image/svg+xml;
|
||||
gzip_vary on;
|
||||
|
||||
# Only connect to this site via HTTPS for the two years
|
||||
|
@ -48,37 +48,62 @@ server {
|
|||
add_header X-Frame-Options "DENY";
|
||||
add_header X-XSS-Protection "1; mode=block";
|
||||
|
||||
# Upload limit for pictshare
|
||||
client_max_body_size 50M;
|
||||
# Upload limit for pictrs
|
||||
client_max_body_size 20M;
|
||||
|
||||
# frontend
|
||||
location / {
|
||||
proxy_pass http://0.0.0.0:8536;
|
||||
# The default ports:
|
||||
# lemmy_ui_port: 1235
|
||||
# lemmy_port: 8536
|
||||
|
||||
set $proxpass "http://0.0.0.0:{{ lemmy_ui_port }}";
|
||||
if ($http_accept = "application/activity+json") {
|
||||
set $proxpass "http://0.0.0.0:{{ lemmy_port }}";
|
||||
}
|
||||
if ($http_accept = "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"") {
|
||||
set $proxpass "http://0.0.0.0:{{ lemmy_port }}";
|
||||
}
|
||||
if ($request_method = POST) {
|
||||
set $proxpass "http://0.0.0.0:{{ lemmy_port }}";
|
||||
}
|
||||
proxy_pass $proxpass;
|
||||
|
||||
rewrite ^(.+)/+$ $1 permanent;
|
||||
|
||||
# Send actual client IP upstream
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# WebSocket support
|
||||
# backend
|
||||
location ~ ^/(api|pictrs|feeds|nodeinfo|.well-known) {
|
||||
proxy_pass http://0.0.0.0:{{ lemmy_port }};
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
# Proxy Cache
|
||||
proxy_cache lemmy_frontend_cache;
|
||||
proxy_cache_use_stale error timeout http_500 http_502 http_503 http_504;
|
||||
proxy_cache_revalidate on;
|
||||
proxy_cache_lock on;
|
||||
proxy_cache_min_uses 5;
|
||||
}
|
||||
# Rate limit
|
||||
limit_req zone=lemmy_ratelimit burst=30 nodelay;
|
||||
|
||||
location /pictshare/ {
|
||||
proxy_pass http://0.0.0.0:8537/;
|
||||
# Add IP forwarding headers
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
if ($request_uri ~ \.(?:ico|gif|jpe?g|png|webp|bmp|mp4)$) {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
}
|
||||
|
||||
|
||||
# Redirect pictshare images to pictrs
|
||||
location ~ /pictshare/(.*)$ {
|
||||
return 301 /pictrs/image/$1;
|
||||
}
|
||||
|
||||
location /iframely/ {
|
||||
proxy_pass http://0.0.0.0:8061/;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -22,27 +22,33 @@
|
|||
|
||||
- name: stop docker-compose
|
||||
docker_compose:
|
||||
project_src: /lemmy/
|
||||
project_src: '{{lemmy_base_dir}}'
|
||||
state: absent
|
||||
|
||||
- name: delete data
|
||||
file: path={{item.path}} state=absent
|
||||
file:
|
||||
path: '{{item.path}}'
|
||||
state: absent
|
||||
with_items:
|
||||
- { path: '/lemmy/' }
|
||||
- { path: '/etc/nginx/sites-enabled/lemmy.conf' }
|
||||
- path: '{{lemmy_base_dir}}'
|
||||
- path: '/etc/nginx/sites-enabled/lemmy.conf'
|
||||
|
||||
- name: Remove a volume
|
||||
docker_volume: name={{item.name}} state=absent
|
||||
docker_volume:
|
||||
name: '{{item.name}}'
|
||||
state: absent
|
||||
with_items:
|
||||
- { name: 'lemmy_lemmy_db' }
|
||||
- { name: 'lemmy_lemmy_pictshare' }
|
||||
- name: 'lemmy_lemmy_db'
|
||||
- name: 'lemmy_lemmy_pictshare'
|
||||
|
||||
- name: delete entire ecloud folder
|
||||
file: path='/mnt/repo-base/' state=absent
|
||||
file:
|
||||
path: '/mnt/repo-base/'
|
||||
state: absent
|
||||
when: delete_certs|bool
|
||||
|
||||
- name: remove certbot cronjob
|
||||
cron:
|
||||
name=certbot-renew-lemmy
|
||||
state=absent
|
||||
name: certbot-renew-lemmy
|
||||
state: absent
|
||||
|
||||
|
|
|
@ -4,13 +4,11 @@
|
|||
"browser": true
|
||||
},
|
||||
"plugins": [
|
||||
"jane",
|
||||
"inferno"
|
||||
"jane"
|
||||
],
|
||||
"extends": [
|
||||
"plugin:jane/recommended",
|
||||
"plugin:jane/typescript",
|
||||
"plugin:inferno/recommended"
|
||||
"plugin:jane/typescript"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
|
@ -32,12 +30,8 @@
|
|||
"eqeqeq": 0,
|
||||
"func-style": 0,
|
||||
"import/no-duplicates": 0,
|
||||
"inferno/jsx-key": 0,
|
||||
"inferno/jsx-no-target-blank": 0,
|
||||
"inferno/jsx-props-class-name": 0,
|
||||
"inferno/no-direct-mutation-state": 0,
|
||||
"inferno/no-unknown-property": 0,
|
||||
"max-statements": 0,
|
||||
"max-params": 0,
|
||||
"new-cap": 0,
|
||||
"no-console": 0,
|
||||
"no-duplicate-imports": 0,
|
4
api_tests/jest.config.js
Normal file
4
api_tests/jest.config.js
Normal file
|
@ -0,0 +1,4 @@
|
|||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
};
|
25
api_tests/package.json
Normal file
25
api_tests/package.json
Normal file
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"name": "api_tests",
|
||||
"version": "0.0.1",
|
||||
"description": "API tests for lemmy backend",
|
||||
"main": "index.js",
|
||||
"repository": "https://github.com/LemmyNet/lemmy",
|
||||
"author": "Dessalines",
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
"lint": "tsc --noEmit && eslint --report-unused-disable-directives --ext .js,.ts,.tsx src",
|
||||
"fix": "prettier --write src && eslint --fix src",
|
||||
"api-test": "jest src/ -i --verbose"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^26.0.20",
|
||||
"eslint": "^7.18.0",
|
||||
"eslint-plugin-jane": "^9.0.3",
|
||||
"jest": "^26.6.3",
|
||||
"lemmy-js-client": "0.10.0-rc.4",
|
||||
"node-fetch": "^2.6.1",
|
||||
"prettier": "^2.1.2",
|
||||
"ts-jest": "^26.4.4",
|
||||
"typescript": "^4.1.3"
|
||||
}
|
||||
}
|
71
api_tests/prepare-drone-federation-test.sh
Executable file
71
api_tests/prepare-drone-federation-test.sh
Executable file
|
@ -0,0 +1,71 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
export LEMMY_TEST_SEND_SYNC=1
|
||||
export RUST_BACKTRACE=1
|
||||
|
||||
for INSTANCE in lemmy_alpha lemmy_beta lemmy_gamma lemmy_delta lemmy_epsilon; do
|
||||
psql "${LEMMY_DATABASE_URL}/lemmy" -c "DROP DATABASE IF EXISTS $INSTANCE"
|
||||
psql "${LEMMY_DATABASE_URL}/lemmy" -c "CREATE DATABASE $INSTANCE"
|
||||
done
|
||||
|
||||
if [ -z "$DO_WRITE_HOSTS_FILE" ]; then
|
||||
if ! grep -q lemmy-alpha /etc/hosts; then
|
||||
echo "Please add the following to your /etc/hosts file, then press enter:
|
||||
|
||||
127.0.0.1 lemmy-alpha
|
||||
127.0.0.1 lemmy-beta
|
||||
127.0.0.1 lemmy-gamma
|
||||
127.0.0.1 lemmy-delta
|
||||
127.0.0.1 lemmy-epsilon"
|
||||
read -p ""
|
||||
fi
|
||||
else
|
||||
for INSTANCE in lemmy-alpha lemmy-beta lemmy-gamma lemmy-delta lemmy-epsilon; do
|
||||
echo "127.0.0.1 $INSTANCE" >> /etc/hosts
|
||||
done
|
||||
fi
|
||||
|
||||
killall lemmy_server || true
|
||||
|
||||
echo "$PWD"
|
||||
|
||||
echo "start alpha"
|
||||
LEMMY_HOSTNAME=lemmy-alpha:8541 \
|
||||
LEMMY_CONFIG_LOCATION=./docker/federation/lemmy_alpha.hjson \
|
||||
LEMMY_DATABASE_URL="${LEMMY_DATABASE_URL}/lemmy_alpha" \
|
||||
LEMMY_HOSTNAME="lemmy-alpha:8541" \
|
||||
target/lemmy_server >/tmp/lemmy_alpha.out 2>&1 &
|
||||
|
||||
echo "start beta"
|
||||
LEMMY_HOSTNAME=lemmy-beta:8551 \
|
||||
LEMMY_CONFIG_LOCATION=./docker/federation/lemmy_beta.hjson \
|
||||
LEMMY_DATABASE_URL="${LEMMY_DATABASE_URL}/lemmy_beta" \
|
||||
target/lemmy_server >/tmp/lemmy_beta.out 2>&1 &
|
||||
|
||||
echo "start gamma"
|
||||
LEMMY_HOSTNAME=lemmy-gamma:8561 \
|
||||
LEMMY_CONFIG_LOCATION=./docker/federation/lemmy_gamma.hjson \
|
||||
LEMMY_DATABASE_URL="${LEMMY_DATABASE_URL}/lemmy_gamma" \
|
||||
target/lemmy_server >/tmp/lemmy_gamma.out 2>&1 &
|
||||
|
||||
echo "start delta"
|
||||
# An instance with only an allowlist for beta
|
||||
LEMMY_HOSTNAME=lemmy-delta:8571 \
|
||||
LEMMY_CONFIG_LOCATION=./docker/federation/lemmy_delta.hjson \
|
||||
LEMMY_DATABASE_URL="${LEMMY_DATABASE_URL}/lemmy_delta" \
|
||||
target/lemmy_server >/tmp/lemmy_delta.out 2>&1 &
|
||||
|
||||
echo "start epsilon"
|
||||
# An instance who has a blocklist, with lemmy-alpha blocked
|
||||
LEMMY_HOSTNAME=lemmy-epsilon:8581 \
|
||||
LEMMY_CONFIG_LOCATION=./docker/federation/lemmy_epsilon.hjson \
|
||||
LEMMY_DATABASE_URL="${LEMMY_DATABASE_URL}/lemmy_epsilon" \
|
||||
target/lemmy_server >/tmp/lemmy_epsilon.out 2>&1 &
|
||||
|
||||
echo "wait for all instances to start"
|
||||
while [[ "$(curl -s -o /dev/null -w '%{http_code}' 'localhost:8541/api/v2/site')" != "200" ]]; do sleep 1; done
|
||||
while [[ "$(curl -s -o /dev/null -w '%{http_code}' 'localhost:8551/api/v2/site')" != "200" ]]; do sleep 1; done
|
||||
while [[ "$(curl -s -o /dev/null -w '%{http_code}' 'localhost:8561/api/v2/site')" != "200" ]]; do sleep 1; done
|
||||
while [[ "$(curl -s -o /dev/null -w '%{http_code}' 'localhost:8571/api/v2/site')" != "200" ]]; do sleep 1; done
|
||||
while [[ "$(curl -s -o /dev/null -w '%{http_code}' 'localhost:8581/api/v2/site')" != "200" ]]; do sleep 1; done
|
20
api_tests/run-federation-test.sh
Executable file
20
api_tests/run-federation-test.sh
Executable file
|
@ -0,0 +1,20 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
export LEMMY_DATABASE_URL=postgres://lemmy:password@localhost:5432
|
||||
|
||||
pushd ..
|
||||
cargo build
|
||||
rm target/lemmy_server || true
|
||||
cp target/debug/lemmy_server target/lemmy_server
|
||||
./api_tests/prepare-drone-federation-test.sh
|
||||
popd
|
||||
|
||||
yarn
|
||||
yarn api-test || true
|
||||
|
||||
killall lemmy_server
|
||||
|
||||
for INSTANCE in lemmy_alpha lemmy_beta lemmy_gamma lemmy_delta lemmy_epsilon; do
|
||||
psql "$LEMMY_DATABASE_URL" -c "DROP DATABASE $INSTANCE"
|
||||
done
|
391
api_tests/src/comment.spec.ts
Normal file
391
api_tests/src/comment.spec.ts
Normal file
|
@ -0,0 +1,391 @@
|
|||
jest.setTimeout(180000);
|
||||
import {
|
||||
alpha,
|
||||
beta,
|
||||
gamma,
|
||||
setupLogins,
|
||||
createPost,
|
||||
getPost,
|
||||
searchComment,
|
||||
likeComment,
|
||||
followBeta,
|
||||
searchForBetaCommunity,
|
||||
createComment,
|
||||
editComment,
|
||||
deleteComment,
|
||||
removeComment,
|
||||
getMentions,
|
||||
searchPost,
|
||||
unfollowRemotes,
|
||||
createCommunity,
|
||||
registerUser,
|
||||
API,
|
||||
} from './shared';
|
||||
import { CommentView } from 'lemmy-js-client';
|
||||
|
||||
import { PostResponse } from 'lemmy-js-client';
|
||||
|
||||
let postRes: PostResponse;
|
||||
|
||||
beforeAll(async () => {
|
||||
await setupLogins();
|
||||
await followBeta(alpha);
|
||||
await followBeta(gamma);
|
||||
let search = await searchForBetaCommunity(alpha);
|
||||
postRes = await createPost(
|
||||
alpha,
|
||||
search.communities.find(c => c.community.local == false).community.id
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await unfollowRemotes(alpha);
|
||||
await unfollowRemotes(gamma);
|
||||
});
|
||||
|
||||
function assertCommentFederation(
|
||||
commentOne: CommentView,
|
||||
commentTwo: CommentView
|
||||
) {
|
||||
expect(commentOne.comment.ap_id).toBe(commentOne.comment.ap_id);
|
||||
expect(commentOne.comment.content).toBe(commentTwo.comment.content);
|
||||
expect(commentOne.creator.name).toBe(commentTwo.creator.name);
|
||||
expect(commentOne.community.actor_id).toBe(commentTwo.community.actor_id);
|
||||
expect(commentOne.comment.published).toBe(commentTwo.comment.published);
|
||||
expect(commentOne.comment.updated).toBe(commentOne.comment.updated);
|
||||
expect(commentOne.comment.deleted).toBe(commentOne.comment.deleted);
|
||||
expect(commentOne.comment.removed).toBe(commentOne.comment.removed);
|
||||
}
|
||||
|
||||
test('Create a comment', async () => {
|
||||
let commentRes = await createComment(alpha, postRes.post_view.post.id);
|
||||
expect(commentRes.comment_view.comment.content).toBeDefined();
|
||||
expect(commentRes.comment_view.community.local).toBe(false);
|
||||
expect(commentRes.comment_view.creator.local).toBe(true);
|
||||
expect(commentRes.comment_view.counts.score).toBe(1);
|
||||
|
||||
// Make sure that comment is liked on beta
|
||||
let searchBeta = await searchComment(beta, commentRes.comment_view.comment);
|
||||
let betaComment = searchBeta.comments[0];
|
||||
expect(betaComment).toBeDefined();
|
||||
expect(betaComment.community.local).toBe(true);
|
||||
expect(betaComment.creator.local).toBe(false);
|
||||
expect(betaComment.counts.score).toBe(1);
|
||||
assertCommentFederation(betaComment, commentRes.comment_view);
|
||||
});
|
||||
|
||||
test('Create a comment in a non-existent post', async () => {
|
||||
let commentRes = await createComment(alpha, -1);
|
||||
expect(commentRes).toStrictEqual({ error: 'couldnt_find_post' });
|
||||
});
|
||||
|
||||
test('Update a comment', async () => {
|
||||
let commentRes = await createComment(alpha, postRes.post_view.post.id);
|
||||
// Federate the comment first
|
||||
let searchBeta = await searchComment(beta, commentRes.comment_view.comment);
|
||||
assertCommentFederation(searchBeta.comments[0], commentRes.comment_view);
|
||||
|
||||
let updateCommentRes = await editComment(
|
||||
alpha,
|
||||
commentRes.comment_view.comment.id
|
||||
);
|
||||
expect(updateCommentRes.comment_view.comment.content).toBe(
|
||||
'A jest test federated comment update'
|
||||
);
|
||||
expect(updateCommentRes.comment_view.community.local).toBe(false);
|
||||
expect(updateCommentRes.comment_view.creator.local).toBe(true);
|
||||
|
||||
// Make sure that post is updated on beta
|
||||
let searchBetaUpdated = await searchComment(
|
||||
beta,
|
||||
commentRes.comment_view.comment
|
||||
);
|
||||
assertCommentFederation(
|
||||
searchBetaUpdated.comments[0],
|
||||
updateCommentRes.comment_view
|
||||
);
|
||||
});
|
||||
|
||||
test('Delete a comment', async () => {
|
||||
let commentRes = await createComment(alpha, postRes.post_view.post.id);
|
||||
|
||||
let deleteCommentRes = await deleteComment(
|
||||
alpha,
|
||||
true,
|
||||
commentRes.comment_view.comment.id
|
||||
);
|
||||
expect(deleteCommentRes.comment_view.comment.deleted).toBe(true);
|
||||
|
||||
// Make sure that comment is undefined on beta
|
||||
let searchBeta = await searchComment(beta, commentRes.comment_view.comment);
|
||||
let betaComment = searchBeta.comments[0];
|
||||
expect(betaComment).toBeUndefined();
|
||||
|
||||
let undeleteCommentRes = await deleteComment(
|
||||
alpha,
|
||||
false,
|
||||
commentRes.comment_view.comment.id
|
||||
);
|
||||
expect(undeleteCommentRes.comment_view.comment.deleted).toBe(false);
|
||||
|
||||
// Make sure that comment is undeleted on beta
|
||||
let searchBeta2 = await searchComment(beta, commentRes.comment_view.comment);
|
||||
let betaComment2 = searchBeta2.comments[0];
|
||||
expect(betaComment2.comment.deleted).toBe(false);
|
||||
assertCommentFederation(
|
||||
searchBeta2.comments[0],
|
||||
undeleteCommentRes.comment_view
|
||||
);
|
||||
});
|
||||
|
||||
test('Remove a comment from admin and community on the same instance', async () => {
|
||||
let commentRes = await createComment(alpha, postRes.post_view.post.id);
|
||||
|
||||
// Get the id for beta
|
||||
let betaCommentId = (
|
||||
await searchComment(beta, commentRes.comment_view.comment)
|
||||
).comments[0].comment.id;
|
||||
|
||||
// The beta admin removes it (the community lives on beta)
|
||||
let removeCommentRes = await removeComment(beta, true, betaCommentId);
|
||||
expect(removeCommentRes.comment_view.comment.removed).toBe(true);
|
||||
|
||||
// Make sure that comment is removed on alpha (it gets pushed since an admin from beta removed it)
|
||||
let refetchedPost = await getPost(alpha, postRes.post_view.post.id);
|
||||
expect(refetchedPost.comments[0].comment.removed).toBe(true);
|
||||
|
||||
let unremoveCommentRes = await removeComment(beta, false, betaCommentId);
|
||||
expect(unremoveCommentRes.comment_view.comment.removed).toBe(false);
|
||||
|
||||
// Make sure that comment is unremoved on beta
|
||||
let refetchedPost2 = await getPost(alpha, postRes.post_view.post.id);
|
||||
expect(refetchedPost2.comments[0].comment.removed).toBe(false);
|
||||
assertCommentFederation(
|
||||
refetchedPost2.comments[0],
|
||||
unremoveCommentRes.comment_view
|
||||
);
|
||||
});
|
||||
|
||||
test('Remove a comment from admin and community on different instance', async () => {
|
||||
let alphaUser = await registerUser(alpha);
|
||||
let newAlphaApi: API = {
|
||||
client: alpha.client,
|
||||
auth: alphaUser.jwt,
|
||||
};
|
||||
|
||||
// New alpha user creates a community, post, and comment.
|
||||
let newCommunity = await createCommunity(newAlphaApi);
|
||||
let newPost = await createPost(
|
||||
newAlphaApi,
|
||||
newCommunity.community_view.community.id
|
||||
);
|
||||
let commentRes = await createComment(newAlphaApi, newPost.post_view.post.id);
|
||||
expect(commentRes.comment_view.comment.content).toBeDefined();
|
||||
|
||||
// Beta searches that to cache it, then removes it
|
||||
let searchBeta = await searchComment(beta, commentRes.comment_view.comment);
|
||||
let betaComment = searchBeta.comments[0];
|
||||
let removeCommentRes = await removeComment(
|
||||
beta,
|
||||
true,
|
||||
betaComment.comment.id
|
||||
);
|
||||
expect(removeCommentRes.comment_view.comment.removed).toBe(true);
|
||||
|
||||
// Make sure its not removed on alpha
|
||||
let refetchedPost = await getPost(newAlphaApi, newPost.post_view.post.id);
|
||||
expect(refetchedPost.comments[0].comment.removed).toBe(false);
|
||||
assertCommentFederation(refetchedPost.comments[0], commentRes.comment_view);
|
||||
});
|
||||
|
||||
test('Unlike a comment', async () => {
|
||||
let commentRes = await createComment(alpha, postRes.post_view.post.id);
|
||||
let unlike = await likeComment(alpha, 0, commentRes.comment_view.comment);
|
||||
expect(unlike.comment_view.counts.score).toBe(0);
|
||||
|
||||
// Make sure that post is unliked on beta
|
||||
let searchBeta = await searchComment(beta, commentRes.comment_view.comment);
|
||||
let betaComment = searchBeta.comments[0];
|
||||
expect(betaComment).toBeDefined();
|
||||
expect(betaComment.community.local).toBe(true);
|
||||
expect(betaComment.creator.local).toBe(false);
|
||||
expect(betaComment.counts.score).toBe(0);
|
||||
});
|
||||
|
||||
test('Federated comment like', async () => {
|
||||
let commentRes = await createComment(alpha, postRes.post_view.post.id);
|
||||
|
||||
// Find the comment on beta
|
||||
let searchBeta = await searchComment(beta, commentRes.comment_view.comment);
|
||||
let betaComment = searchBeta.comments[0];
|
||||
|
||||
let like = await likeComment(beta, 1, betaComment.comment);
|
||||
expect(like.comment_view.counts.score).toBe(2);
|
||||
|
||||
// Get the post from alpha, check the likes
|
||||
let post = await getPost(alpha, postRes.post_view.post.id);
|
||||
expect(post.comments[0].counts.score).toBe(2);
|
||||
});
|
||||
|
||||
test('Reply to a comment', async () => {
|
||||
// Create a comment on alpha, find it on beta
|
||||
let commentRes = await createComment(alpha, postRes.post_view.post.id);
|
||||
let searchBeta = await searchComment(beta, commentRes.comment_view.comment);
|
||||
let betaComment = searchBeta.comments[0];
|
||||
|
||||
// find that comment id on beta
|
||||
|
||||
// Reply from beta
|
||||
let replyRes = await createComment(
|
||||
beta,
|
||||
betaComment.post.id,
|
||||
betaComment.comment.id
|
||||
);
|
||||
expect(replyRes.comment_view.comment.content).toBeDefined();
|
||||
expect(replyRes.comment_view.community.local).toBe(true);
|
||||
expect(replyRes.comment_view.creator.local).toBe(true);
|
||||
expect(replyRes.comment_view.comment.parent_id).toBe(betaComment.comment.id);
|
||||
expect(replyRes.comment_view.counts.score).toBe(1);
|
||||
|
||||
// Make sure that comment is seen on alpha
|
||||
// TODO not sure why, but a searchComment back to alpha, for the ap_id of betas
|
||||
// comment, isn't working.
|
||||
// let searchAlpha = await searchComment(alpha, replyRes.comment);
|
||||
let post = await getPost(alpha, postRes.post_view.post.id);
|
||||
let alphaComment = post.comments[0];
|
||||
expect(alphaComment.comment.content).toBeDefined();
|
||||
expect(alphaComment.comment.parent_id).toBe(post.comments[1].comment.id);
|
||||
expect(alphaComment.community.local).toBe(false);
|
||||
expect(alphaComment.creator.local).toBe(false);
|
||||
expect(alphaComment.counts.score).toBe(1);
|
||||
assertCommentFederation(alphaComment, replyRes.comment_view);
|
||||
});
|
||||
|
||||
test('Mention beta', async () => {
|
||||
// Create a mention on alpha
|
||||
let mentionContent = 'A test mention of @lemmy_beta@lemmy-beta:8551';
|
||||
let commentRes = await createComment(alpha, postRes.post_view.post.id);
|
||||
let mentionRes = await createComment(
|
||||
alpha,
|
||||
postRes.post_view.post.id,
|
||||
commentRes.comment_view.comment.id,
|
||||
mentionContent
|
||||
);
|
||||
expect(mentionRes.comment_view.comment.content).toBeDefined();
|
||||
expect(mentionRes.comment_view.community.local).toBe(false);
|
||||
expect(mentionRes.comment_view.creator.local).toBe(true);
|
||||
expect(mentionRes.comment_view.counts.score).toBe(1);
|
||||
|
||||
let mentionsRes = await getMentions(beta);
|
||||
expect(mentionsRes.mentions[0].comment.content).toBeDefined();
|
||||
expect(mentionsRes.mentions[0].community.local).toBe(true);
|
||||
expect(mentionsRes.mentions[0].creator.local).toBe(false);
|
||||
expect(mentionsRes.mentions[0].counts.score).toBe(1);
|
||||
});
|
||||
|
||||
test('Comment Search', async () => {
|
||||
let commentRes = await createComment(alpha, postRes.post_view.post.id);
|
||||
let searchBeta = await searchComment(beta, commentRes.comment_view.comment);
|
||||
assertCommentFederation(searchBeta.comments[0], commentRes.comment_view);
|
||||
});
|
||||
|
||||
test('A and G subscribe to B (center) A posts, G mentions B, it gets announced to A', async () => {
|
||||
// Create a local post
|
||||
let alphaPost = await createPost(alpha, 2);
|
||||
expect(alphaPost.post_view.community.local).toBe(true);
|
||||
|
||||
// Make sure gamma sees it
|
||||
let search = await searchPost(gamma, alphaPost.post_view.post);
|
||||
let gammaPost = search.posts[0];
|
||||
|
||||
let commentContent =
|
||||
'A jest test federated comment announce, lets mention @lemmy_beta@lemmy-beta:8551';
|
||||
let commentRes = await createComment(
|
||||
gamma,
|
||||
gammaPost.post.id,
|
||||
undefined,
|
||||
commentContent
|
||||
);
|
||||
expect(commentRes.comment_view.comment.content).toBe(commentContent);
|
||||
expect(commentRes.comment_view.community.local).toBe(false);
|
||||
expect(commentRes.comment_view.creator.local).toBe(true);
|
||||
expect(commentRes.comment_view.counts.score).toBe(1);
|
||||
|
||||
// Make sure alpha sees it
|
||||
let alphaPost2 = await getPost(alpha, alphaPost.post_view.post.id);
|
||||
expect(alphaPost2.comments[0].comment.content).toBe(commentContent);
|
||||
expect(alphaPost2.comments[0].community.local).toBe(true);
|
||||
expect(alphaPost2.comments[0].creator.local).toBe(false);
|
||||
expect(alphaPost2.comments[0].counts.score).toBe(1);
|
||||
assertCommentFederation(alphaPost2.comments[0], commentRes.comment_view);
|
||||
|
||||
// Make sure beta has mentions
|
||||
let mentionsRes = await getMentions(beta);
|
||||
expect(mentionsRes.mentions[0].comment.content).toBe(commentContent);
|
||||
expect(mentionsRes.mentions[0].community.local).toBe(false);
|
||||
expect(mentionsRes.mentions[0].creator.local).toBe(false);
|
||||
// TODO this is failing because fetchInReplyTos aren't getting score
|
||||
// expect(mentionsRes.mentions[0].score).toBe(1);
|
||||
});
|
||||
|
||||
test('Fetch in_reply_tos: A is unsubbed from B, B makes a post, and some embedded comments, A subs to B, B updates the lowest level comment, A fetches both the post and all the inreplyto comments for that post.', async () => {
|
||||
// Unfollow all remote communities
|
||||
let followed = await unfollowRemotes(alpha);
|
||||
expect(
|
||||
followed.communities.filter(c => c.community.local == false).length
|
||||
).toBe(0);
|
||||
|
||||
// B creates a post, and two comments, should be invisible to A
|
||||
let postRes = await createPost(beta, 2);
|
||||
expect(postRes.post_view.post.name).toBeDefined();
|
||||
|
||||
let parentCommentContent = 'An invisible top level comment from beta';
|
||||
let parentCommentRes = await createComment(
|
||||
beta,
|
||||
postRes.post_view.post.id,
|
||||
undefined,
|
||||
parentCommentContent
|
||||
);
|
||||
expect(parentCommentRes.comment_view.comment.content).toBe(
|
||||
parentCommentContent
|
||||
);
|
||||
|
||||
// B creates a comment, then a child one of that.
|
||||
let childCommentContent = 'An invisible child comment from beta';
|
||||
let childCommentRes = await createComment(
|
||||
beta,
|
||||
postRes.post_view.post.id,
|
||||
parentCommentRes.comment_view.comment.id,
|
||||
childCommentContent
|
||||
);
|
||||
expect(childCommentRes.comment_view.comment.content).toBe(
|
||||
childCommentContent
|
||||
);
|
||||
|
||||
// Follow beta again
|
||||
let follow = await followBeta(alpha);
|
||||
expect(follow.community_view.community.local).toBe(false);
|
||||
expect(follow.community_view.community.name).toBe('main');
|
||||
|
||||
// An update to the child comment on beta, should push the post, parent, and child to alpha now
|
||||
let updatedCommentContent = 'An update child comment from beta';
|
||||
let updateRes = await editComment(
|
||||
beta,
|
||||
childCommentRes.comment_view.comment.id,
|
||||
updatedCommentContent
|
||||
);
|
||||
expect(updateRes.comment_view.comment.content).toBe(updatedCommentContent);
|
||||
|
||||
// Get the post from alpha
|
||||
let search = await searchPost(alpha, postRes.post_view.post);
|
||||
let alphaPostB = search.posts[0];
|
||||
|
||||
let alphaPost = await getPost(alpha, alphaPostB.post.id);
|
||||
expect(alphaPost.post_view.post.name).toBeDefined();
|
||||
assertCommentFederation(alphaPost.comments[1], parentCommentRes.comment_view);
|
||||
assertCommentFederation(alphaPost.comments[0], updateRes.comment_view);
|
||||
expect(alphaPost.post_view.community.local).toBe(false);
|
||||
expect(alphaPost.post_view.creator.local).toBe(false);
|
||||
|
||||
await unfollowRemotes(alpha);
|
||||
});
|
166
api_tests/src/community.spec.ts
Normal file
166
api_tests/src/community.spec.ts
Normal file
|
@ -0,0 +1,166 @@
|
|||
jest.setTimeout(120000);
|
||||
import {
|
||||
alpha,
|
||||
beta,
|
||||
setupLogins,
|
||||
searchForCommunity,
|
||||
createCommunity,
|
||||
deleteCommunity,
|
||||
removeCommunity,
|
||||
getCommunity,
|
||||
followCommunity,
|
||||
} from './shared';
|
||||
import { CommunityView } from 'lemmy-js-client';
|
||||
|
||||
beforeAll(async () => {
|
||||
await setupLogins();
|
||||
});
|
||||
|
||||
function assertCommunityFederation(
|
||||
communityOne: CommunityView,
|
||||
communityTwo: CommunityView
|
||||
) {
|
||||
expect(communityOne.community.actor_id).toBe(communityTwo.community.actor_id);
|
||||
expect(communityOne.community.name).toBe(communityTwo.community.name);
|
||||
expect(communityOne.community.title).toBe(communityTwo.community.title);
|
||||
expect(communityOne.community.description).toBe(
|
||||
communityTwo.community.description
|
||||
);
|
||||
expect(communityOne.community.icon).toBe(communityTwo.community.icon);
|
||||
expect(communityOne.community.banner).toBe(communityTwo.community.banner);
|
||||
expect(communityOne.community.published).toBe(
|
||||
communityTwo.community.published
|
||||
);
|
||||
expect(communityOne.creator.actor_id).toBe(communityTwo.creator.actor_id);
|
||||
expect(communityOne.community.nsfw).toBe(communityTwo.community.nsfw);
|
||||
expect(communityOne.community.removed).toBe(communityTwo.community.removed);
|
||||
expect(communityOne.community.deleted).toBe(communityTwo.community.deleted);
|
||||
}
|
||||
|
||||
test('Create community', async () => {
|
||||
let communityRes = await createCommunity(alpha);
|
||||
expect(communityRes.community_view.community.name).toBeDefined();
|
||||
|
||||
// A dupe check
|
||||
let prevName = communityRes.community_view.community.name;
|
||||
let communityRes2: any = await createCommunity(alpha, prevName);
|
||||
expect(communityRes2['error']).toBe('community_already_exists');
|
||||
|
||||
// Cache the community on beta, make sure it has the other fields
|
||||
let searchShort = `!${prevName}@lemmy-alpha:8541`;
|
||||
let search = await searchForCommunity(beta, searchShort);
|
||||
let communityOnBeta = search.communities[0];
|
||||
assertCommunityFederation(communityOnBeta, communityRes.community_view);
|
||||
});
|
||||
|
||||
test('Delete community', async () => {
|
||||
let communityRes = await createCommunity(beta);
|
||||
|
||||
// Cache the community on Alpha
|
||||
let searchShort = `!${communityRes.community_view.community.name}@lemmy-beta:8551`;
|
||||
let search = await searchForCommunity(alpha, searchShort);
|
||||
let communityOnAlpha = search.communities[0];
|
||||
assertCommunityFederation(communityOnAlpha, communityRes.community_view);
|
||||
|
||||
// Follow the community from alpha
|
||||
let follow = await followCommunity(
|
||||
alpha,
|
||||
true,
|
||||
communityOnAlpha.community.id
|
||||
);
|
||||
|
||||
// Make sure the follow response went through
|
||||
expect(follow.community_view.community.local).toBe(false);
|
||||
|
||||
let deleteCommunityRes = await deleteCommunity(
|
||||
beta,
|
||||
true,
|
||||
communityRes.community_view.community.id
|
||||
);
|
||||
expect(deleteCommunityRes.community_view.community.deleted).toBe(true);
|
||||
|
||||
// Make sure it got deleted on A
|
||||
let communityOnAlphaDeleted = await getCommunity(
|
||||
alpha,
|
||||
communityOnAlpha.community.id
|
||||
);
|
||||
expect(communityOnAlphaDeleted.community_view.community.deleted).toBe(true);
|
||||
|
||||
// Undelete
|
||||
let undeleteCommunityRes = await deleteCommunity(
|
||||
beta,
|
||||
false,
|
||||
communityRes.community_view.community.id
|
||||
);
|
||||
expect(undeleteCommunityRes.community_view.community.deleted).toBe(false);
|
||||
|
||||
// Make sure it got undeleted on A
|
||||
let communityOnAlphaUnDeleted = await getCommunity(
|
||||
alpha,
|
||||
communityOnAlpha.community.id
|
||||
);
|
||||
expect(communityOnAlphaUnDeleted.community_view.community.deleted).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test('Remove community', async () => {
|
||||
let communityRes = await createCommunity(beta);
|
||||
|
||||
// Cache the community on Alpha
|
||||
let searchShort = `!${communityRes.community_view.community.name}@lemmy-beta:8551`;
|
||||
let search = await searchForCommunity(alpha, searchShort);
|
||||
let communityOnAlpha = search.communities[0];
|
||||
assertCommunityFederation(communityOnAlpha, communityRes.community_view);
|
||||
|
||||
// Follow the community from alpha
|
||||
let follow = await followCommunity(
|
||||
alpha,
|
||||
true,
|
||||
communityOnAlpha.community.id
|
||||
);
|
||||
|
||||
// Make sure the follow response went through
|
||||
expect(follow.community_view.community.local).toBe(false);
|
||||
|
||||
let removeCommunityRes = await removeCommunity(
|
||||
beta,
|
||||
true,
|
||||
communityRes.community_view.community.id
|
||||
);
|
||||
expect(removeCommunityRes.community_view.community.removed).toBe(true);
|
||||
|
||||
// Make sure it got Removed on A
|
||||
let communityOnAlphaRemoved = await getCommunity(
|
||||
alpha,
|
||||
communityOnAlpha.community.id
|
||||
);
|
||||
expect(communityOnAlphaRemoved.community_view.community.removed).toBe(true);
|
||||
|
||||
// unremove
|
||||
let unremoveCommunityRes = await removeCommunity(
|
||||
beta,
|
||||
false,
|
||||
communityRes.community_view.community.id
|
||||
);
|
||||
expect(unremoveCommunityRes.community_view.community.removed).toBe(false);
|
||||
|
||||
// Make sure it got undeleted on A
|
||||
let communityOnAlphaUnRemoved = await getCommunity(
|
||||
alpha,
|
||||
communityOnAlpha.community.id
|
||||
);
|
||||
expect(communityOnAlphaUnRemoved.community_view.community.removed).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test('Search for beta community', async () => {
|
||||
let communityRes = await createCommunity(beta);
|
||||
expect(communityRes.community_view.community.name).toBeDefined();
|
||||
|
||||
let searchShort = `!${communityRes.community_view.community.name}@lemmy-beta:8551`;
|
||||
let search = await searchForCommunity(alpha, searchShort);
|
||||
let communityOnAlpha = search.communities[0];
|
||||
assertCommunityFederation(communityOnAlpha, communityRes.community_view);
|
||||
});
|
45
api_tests/src/follow.spec.ts
Normal file
45
api_tests/src/follow.spec.ts
Normal file
|
@ -0,0 +1,45 @@
|
|||
jest.setTimeout(120000);
|
||||
import {
|
||||
alpha,
|
||||
setupLogins,
|
||||
searchForBetaCommunity,
|
||||
followCommunity,
|
||||
checkFollowedCommunities,
|
||||
unfollowRemotes,
|
||||
} from './shared';
|
||||
|
||||
beforeAll(async () => {
|
||||
await setupLogins();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await unfollowRemotes(alpha);
|
||||
});
|
||||
|
||||
test('Follow federated community', async () => {
|
||||
let search = await searchForBetaCommunity(alpha); // TODO sometimes this is returning null?
|
||||
let follow = await followCommunity(
|
||||
alpha,
|
||||
true,
|
||||
search.communities[0].community.id
|
||||
);
|
||||
|
||||
// Make sure the follow response went through
|
||||
expect(follow.community_view.community.local).toBe(false);
|
||||
expect(follow.community_view.community.name).toBe('main');
|
||||
|
||||
// Check it from local
|
||||
let followCheck = await checkFollowedCommunities(alpha);
|
||||
let remoteCommunityId = followCheck.communities.find(
|
||||
c => c.community.local == false
|
||||
).community.id;
|
||||
expect(remoteCommunityId).toBeDefined();
|
||||
|
||||
// Test an unfollow
|
||||
let unfollow = await followCommunity(alpha, false, remoteCommunityId);
|
||||
expect(unfollow.community_view.community.local).toBe(false);
|
||||
|
||||
// Make sure you are unsubbed locally
|
||||
let unfollowCheck = await checkFollowedCommunities(alpha);
|
||||
expect(unfollowCheck.communities.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
359
api_tests/src/post.spec.ts
Normal file
359
api_tests/src/post.spec.ts
Normal file
|
@ -0,0 +1,359 @@
|
|||
jest.setTimeout(120000);
|
||||
import {
|
||||
alpha,
|
||||
beta,
|
||||
gamma,
|
||||
delta,
|
||||
epsilon,
|
||||
setupLogins,
|
||||
createPost,
|
||||
editPost,
|
||||
stickyPost,
|
||||
lockPost,
|
||||
searchPost,
|
||||
likePost,
|
||||
followBeta,
|
||||
searchForBetaCommunity,
|
||||
createComment,
|
||||
deletePost,
|
||||
removePost,
|
||||
getPost,
|
||||
unfollowRemotes,
|
||||
searchForUser,
|
||||
banPersonFromSite,
|
||||
searchPostLocal,
|
||||
banPersonFromCommunity,
|
||||
} from './shared';
|
||||
import { PostView, CommunityView } from 'lemmy-js-client';
|
||||
|
||||
let betaCommunity: CommunityView;
|
||||
|
||||
beforeAll(async () => {
|
||||
await setupLogins();
|
||||
let search = await searchForBetaCommunity(alpha);
|
||||
betaCommunity = search.communities[0];
|
||||
await unfollows();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await unfollows();
|
||||
});
|
||||
|
||||
async function unfollows() {
|
||||
await unfollowRemotes(alpha);
|
||||
await unfollowRemotes(gamma);
|
||||
await unfollowRemotes(delta);
|
||||
await unfollowRemotes(epsilon);
|
||||
}
|
||||
|
||||
function assertPostFederation(postOne: PostView, postTwo: PostView) {
|
||||
expect(postOne.post.ap_id).toBe(postTwo.post.ap_id);
|
||||
expect(postOne.post.name).toBe(postTwo.post.name);
|
||||
expect(postOne.post.body).toBe(postTwo.post.body);
|
||||
expect(postOne.post.url).toBe(postTwo.post.url);
|
||||
expect(postOne.post.nsfw).toBe(postTwo.post.nsfw);
|
||||
expect(postOne.post.embed_title).toBe(postTwo.post.embed_title);
|
||||
expect(postOne.post.embed_description).toBe(postTwo.post.embed_description);
|
||||
expect(postOne.post.embed_html).toBe(postTwo.post.embed_html);
|
||||
expect(postOne.post.published).toBe(postTwo.post.published);
|
||||
expect(postOne.community.actor_id).toBe(postTwo.community.actor_id);
|
||||
expect(postOne.post.locked).toBe(postTwo.post.locked);
|
||||
expect(postOne.post.removed).toBe(postTwo.post.removed);
|
||||
expect(postOne.post.deleted).toBe(postTwo.post.deleted);
|
||||
}
|
||||
|
||||
test('Create a post', async () => {
|
||||
let postRes = await createPost(alpha, betaCommunity.community.id);
|
||||
expect(postRes.post_view.post).toBeDefined();
|
||||
expect(postRes.post_view.community.local).toBe(false);
|
||||
expect(postRes.post_view.creator.local).toBe(true);
|
||||
expect(postRes.post_view.counts.score).toBe(1);
|
||||
|
||||
// Make sure that post is liked on beta
|
||||
let searchBeta = await searchPost(beta, postRes.post_view.post);
|
||||
let betaPost = searchBeta.posts[0];
|
||||
|
||||
expect(betaPost).toBeDefined();
|
||||
expect(betaPost.community.local).toBe(true);
|
||||
expect(betaPost.creator.local).toBe(false);
|
||||
expect(betaPost.counts.score).toBe(1);
|
||||
assertPostFederation(betaPost, postRes.post_view);
|
||||
|
||||
// Delta only follows beta, so it should not see an alpha ap_id
|
||||
let searchDelta = await searchPost(delta, postRes.post_view.post);
|
||||
expect(searchDelta.posts[0]).toBeUndefined();
|
||||
|
||||
// Epsilon has alpha blocked, it should not see the alpha post
|
||||
let searchEpsilon = await searchPost(epsilon, postRes.post_view.post);
|
||||
expect(searchEpsilon.posts[0]).toBeUndefined();
|
||||
});
|
||||
|
||||
test('Create a post in a non-existent community', async () => {
|
||||
let postRes = await createPost(alpha, -2);
|
||||
expect(postRes).toStrictEqual({ error: 'couldnt_create_post' });
|
||||
});
|
||||
|
||||
test('Unlike a post', async () => {
|
||||
let postRes = await createPost(alpha, betaCommunity.community.id);
|
||||
let unlike = await likePost(alpha, 0, postRes.post_view.post);
|
||||
expect(unlike.post_view.counts.score).toBe(0);
|
||||
|
||||
// Try to unlike it again, make sure it stays at 0
|
||||
let unlike2 = await likePost(alpha, 0, postRes.post_view.post);
|
||||
expect(unlike2.post_view.counts.score).toBe(0);
|
||||
|
||||
// Make sure that post is unliked on beta
|
||||
let searchBeta = await searchPost(beta, postRes.post_view.post);
|
||||
let betaPost = searchBeta.posts[0];
|
||||
expect(betaPost).toBeDefined();
|
||||
expect(betaPost.community.local).toBe(true);
|
||||
expect(betaPost.creator.local).toBe(false);
|
||||
expect(betaPost.counts.score).toBe(0);
|
||||
assertPostFederation(betaPost, postRes.post_view);
|
||||
});
|
||||
|
||||
test('Update a post', async () => {
|
||||
let postRes = await createPost(alpha, betaCommunity.community.id);
|
||||
|
||||
let updatedName = 'A jest test federated post, updated';
|
||||
let updatedPost = await editPost(alpha, postRes.post_view.post);
|
||||
expect(updatedPost.post_view.post.name).toBe(updatedName);
|
||||
expect(updatedPost.post_view.community.local).toBe(false);
|
||||
expect(updatedPost.post_view.creator.local).toBe(true);
|
||||
|
||||
// Make sure that post is updated on beta
|
||||
let searchBeta = await searchPost(beta, postRes.post_view.post);
|
||||
let betaPost = searchBeta.posts[0];
|
||||
expect(betaPost.community.local).toBe(true);
|
||||
expect(betaPost.creator.local).toBe(false);
|
||||
expect(betaPost.post.name).toBe(updatedName);
|
||||
assertPostFederation(betaPost, updatedPost.post_view);
|
||||
|
||||
// Make sure lemmy beta cannot update the post
|
||||
let updatedPostBeta = await editPost(beta, betaPost.post);
|
||||
expect(updatedPostBeta).toStrictEqual({ error: 'no_post_edit_allowed' });
|
||||
});
|
||||
|
||||
test('Sticky a post', async () => {
|
||||
let postRes = await createPost(alpha, betaCommunity.community.id);
|
||||
|
||||
let stickiedPostRes = await stickyPost(alpha, true, postRes.post_view.post);
|
||||
expect(stickiedPostRes.post_view.post.stickied).toBe(true);
|
||||
|
||||
// Make sure that post is stickied on beta
|
||||
let searchBeta = await searchPost(beta, postRes.post_view.post);
|
||||
let betaPost = searchBeta.posts[0];
|
||||
expect(betaPost.community.local).toBe(true);
|
||||
expect(betaPost.creator.local).toBe(false);
|
||||
expect(betaPost.post.stickied).toBe(true);
|
||||
|
||||
// Unsticky a post
|
||||
let unstickiedPost = await stickyPost(alpha, false, postRes.post_view.post);
|
||||
expect(unstickiedPost.post_view.post.stickied).toBe(false);
|
||||
|
||||
// Make sure that post is unstickied on beta
|
||||
let searchBeta2 = await searchPost(beta, postRes.post_view.post);
|
||||
let betaPost2 = searchBeta2.posts[0];
|
||||
expect(betaPost2.community.local).toBe(true);
|
||||
expect(betaPost2.creator.local).toBe(false);
|
||||
expect(betaPost2.post.stickied).toBe(false);
|
||||
|
||||
// Make sure that gamma cannot sticky the post on beta
|
||||
let searchGamma = await searchPost(gamma, postRes.post_view.post);
|
||||
let gammaPost = searchGamma.posts[0];
|
||||
let gammaTrySticky = await stickyPost(gamma, true, gammaPost.post);
|
||||
let searchBeta3 = await searchPost(beta, postRes.post_view.post);
|
||||
let betaPost3 = searchBeta3.posts[0];
|
||||
expect(gammaTrySticky.post_view.post.stickied).toBe(true);
|
||||
expect(betaPost3.post.stickied).toBe(false);
|
||||
});
|
||||
|
||||
test('Lock a post', async () => {
|
||||
let postRes = await createPost(alpha, betaCommunity.community.id);
|
||||
|
||||
// Lock the post
|
||||
let lockedPostRes = await lockPost(alpha, true, postRes.post_view.post);
|
||||
expect(lockedPostRes.post_view.post.locked).toBe(true);
|
||||
|
||||
// Make sure that post is locked on beta
|
||||
let searchBeta = await searchPostLocal(beta, postRes.post_view.post);
|
||||
let betaPost1 = searchBeta.posts[0];
|
||||
expect(betaPost1.post.locked).toBe(true);
|
||||
|
||||
// Try to make a new comment there, on alpha
|
||||
let comment: any = await createComment(alpha, postRes.post_view.post.id);
|
||||
expect(comment['error']).toBe('locked');
|
||||
|
||||
// Unlock a post
|
||||
let unlockedPost = await lockPost(alpha, false, postRes.post_view.post);
|
||||
expect(unlockedPost.post_view.post.locked).toBe(false);
|
||||
|
||||
// Make sure that post is unlocked on beta
|
||||
let searchBeta2 = await searchPost(beta, postRes.post_view.post);
|
||||
let betaPost2 = searchBeta2.posts[0];
|
||||
expect(betaPost2.community.local).toBe(true);
|
||||
expect(betaPost2.creator.local).toBe(false);
|
||||
expect(betaPost2.post.locked).toBe(false);
|
||||
|
||||
// Try to create a new comment, on beta
|
||||
let commentBeta = await createComment(beta, betaPost2.post.id);
|
||||
expect(commentBeta).toBeDefined();
|
||||
});
|
||||
|
||||
test('Delete a post', async () => {
|
||||
let postRes = await createPost(alpha, betaCommunity.community.id);
|
||||
expect(postRes.post_view.post).toBeDefined();
|
||||
|
||||
let deletedPost = await deletePost(alpha, true, postRes.post_view.post);
|
||||
expect(deletedPost.post_view.post.deleted).toBe(true);
|
||||
|
||||
// Make sure lemmy beta sees post is deleted
|
||||
let searchBeta = await searchPost(beta, postRes.post_view.post);
|
||||
let betaPost = searchBeta.posts[0];
|
||||
// This will be undefined because of the tombstone
|
||||
expect(betaPost).toBeUndefined();
|
||||
|
||||
// Undelete
|
||||
let undeletedPost = await deletePost(alpha, false, postRes.post_view.post);
|
||||
expect(undeletedPost.post_view.post.deleted).toBe(false);
|
||||
|
||||
// Make sure lemmy beta sees post is undeleted
|
||||
let searchBeta2 = await searchPost(beta, postRes.post_view.post);
|
||||
let betaPost2 = searchBeta2.posts[0];
|
||||
expect(betaPost2.post.deleted).toBe(false);
|
||||
assertPostFederation(betaPost2, undeletedPost.post_view);
|
||||
|
||||
// Make sure lemmy beta cannot delete the post
|
||||
let deletedPostBeta = await deletePost(beta, true, betaPost2.post);
|
||||
expect(deletedPostBeta).toStrictEqual({ error: 'no_post_edit_allowed' });
|
||||
});
|
||||
|
||||
test('Remove a post from admin and community on different instance', async () => {
|
||||
let postRes = await createPost(alpha, betaCommunity.community.id);
|
||||
|
||||
let removedPost = await removePost(alpha, true, postRes.post_view.post);
|
||||
expect(removedPost.post_view.post.removed).toBe(true);
|
||||
|
||||
// Make sure lemmy beta sees post is NOT removed
|
||||
let searchBeta = await searchPost(beta, postRes.post_view.post);
|
||||
let betaPost = searchBeta.posts[0];
|
||||
expect(betaPost.post.removed).toBe(false);
|
||||
|
||||
// Undelete
|
||||
let undeletedPost = await removePost(alpha, false, postRes.post_view.post);
|
||||
expect(undeletedPost.post_view.post.removed).toBe(false);
|
||||
|
||||
// Make sure lemmy beta sees post is undeleted
|
||||
let searchBeta2 = await searchPost(beta, postRes.post_view.post);
|
||||
let betaPost2 = searchBeta2.posts[0];
|
||||
expect(betaPost2.post.removed).toBe(false);
|
||||
assertPostFederation(betaPost2, undeletedPost.post_view);
|
||||
});
|
||||
|
||||
test('Remove a post from admin and community on same instance', async () => {
|
||||
await followBeta(alpha);
|
||||
let postRes = await createPost(alpha, betaCommunity.community.id);
|
||||
expect(postRes.post_view.post).toBeDefined();
|
||||
|
||||
// Get the id for beta
|
||||
let searchBeta = await searchPostLocal(beta, postRes.post_view.post);
|
||||
let betaPost = searchBeta.posts[0];
|
||||
expect(betaPost).toBeDefined();
|
||||
|
||||
// The beta admin removes it (the community lives on beta)
|
||||
let removePostRes = await removePost(beta, true, betaPost.post);
|
||||
expect(removePostRes.post_view.post.removed).toBe(true);
|
||||
|
||||
// Make sure lemmy alpha sees post is removed
|
||||
let alphaPost = await getPost(alpha, postRes.post_view.post.id);
|
||||
// expect(alphaPost.post_view.post.removed).toBe(true); // TODO this shouldn't be commented
|
||||
// assertPostFederation(alphaPost.post_view, removePostRes.post_view);
|
||||
|
||||
// Undelete
|
||||
let undeletedPost = await removePost(beta, false, betaPost.post);
|
||||
expect(undeletedPost.post_view.post.removed).toBe(false);
|
||||
|
||||
// Make sure lemmy alpha sees post is undeleted
|
||||
let alphaPost2 = await getPost(alpha, postRes.post_view.post.id);
|
||||
expect(alphaPost2.post_view.post.removed).toBe(false);
|
||||
assertPostFederation(alphaPost2.post_view, undeletedPost.post_view);
|
||||
await unfollowRemotes(alpha);
|
||||
});
|
||||
|
||||
test('Search for a post', async () => {
|
||||
await unfollowRemotes(alpha);
|
||||
let postRes = await createPost(alpha, betaCommunity.community.id);
|
||||
expect(postRes.post_view.post).toBeDefined();
|
||||
|
||||
let searchBeta = await searchPost(beta, postRes.post_view.post);
|
||||
|
||||
expect(searchBeta.posts[0].post.name).toBeDefined();
|
||||
});
|
||||
|
||||
test('A and G subscribe to B (center) A posts, it gets announced to G', async () => {
|
||||
let postRes = await createPost(alpha, betaCommunity.community.id);
|
||||
expect(postRes.post_view.post).toBeDefined();
|
||||
|
||||
let search2 = await searchPost(gamma, postRes.post_view.post);
|
||||
expect(search2.posts[0].post.name).toBeDefined();
|
||||
});
|
||||
|
||||
test('Enforce site ban for federated user', async () => {
|
||||
let alphaShortname = `@lemmy_alpha@lemmy-alpha:8541`;
|
||||
let userSearch = await searchForUser(beta, alphaShortname);
|
||||
let alphaUser = userSearch.users[0];
|
||||
expect(alphaUser).toBeDefined();
|
||||
|
||||
// ban alpha from beta site
|
||||
let banAlpha = await banPersonFromSite(beta, alphaUser.person.id, true);
|
||||
expect(banAlpha.banned).toBe(true);
|
||||
|
||||
// Alpha makes post on beta
|
||||
let postRes = await createPost(alpha, betaCommunity.community.id);
|
||||
expect(postRes.post_view.post).toBeDefined();
|
||||
expect(postRes.post_view.community.local).toBe(false);
|
||||
expect(postRes.post_view.creator.local).toBe(true);
|
||||
expect(postRes.post_view.counts.score).toBe(1);
|
||||
|
||||
// Make sure that post doesn't make it to beta
|
||||
let searchBeta = await searchPostLocal(beta, postRes.post_view.post);
|
||||
let betaPost = searchBeta.posts[0];
|
||||
expect(betaPost).toBeUndefined();
|
||||
|
||||
// Unban alpha
|
||||
let unBanAlpha = await banPersonFromSite(beta, alphaUser.person.id, false);
|
||||
expect(unBanAlpha.banned).toBe(false);
|
||||
});
|
||||
|
||||
test('Enforce community ban for federated user', async () => {
|
||||
let alphaShortname = `@lemmy_alpha@lemmy-alpha:8541`;
|
||||
let userSearch = await searchForUser(beta, alphaShortname);
|
||||
let alphaUser = userSearch.users[0];
|
||||
expect(alphaUser).toBeDefined();
|
||||
|
||||
// ban alpha from beta site
|
||||
await banPersonFromCommunity(beta, alphaUser.person.id, 2, false);
|
||||
let banAlpha = await banPersonFromCommunity(beta, alphaUser.person.id, 2, true);
|
||||
expect(banAlpha.banned).toBe(true);
|
||||
|
||||
// Alpha makes post on beta
|
||||
let postRes = await createPost(alpha, betaCommunity.community.id);
|
||||
expect(postRes.post_view.post).toBeDefined();
|
||||
expect(postRes.post_view.community.local).toBe(false);
|
||||
expect(postRes.post_view.creator.local).toBe(true);
|
||||
expect(postRes.post_view.counts.score).toBe(1);
|
||||
|
||||
// Make sure that post doesn't make it to beta community
|
||||
let searchBeta = await searchPostLocal(beta, postRes.post_view.post);
|
||||
let betaPost = searchBeta.posts[0];
|
||||
expect(betaPost).toBeUndefined();
|
||||
|
||||
// Unban alpha
|
||||
let unBanAlpha = await banPersonFromCommunity(
|
||||
beta,
|
||||
alphaUser.person.id,
|
||||
2,
|
||||
false
|
||||
);
|
||||
expect(unBanAlpha.banned).toBe(false);
|
||||
});
|
90
api_tests/src/private_message.spec.ts
Normal file
90
api_tests/src/private_message.spec.ts
Normal file
|
@ -0,0 +1,90 @@
|
|||
jest.setTimeout(120000);
|
||||
import {
|
||||
alpha,
|
||||
beta,
|
||||
setupLogins,
|
||||
followBeta,
|
||||
createPrivateMessage,
|
||||
editPrivateMessage,
|
||||
listPrivateMessages,
|
||||
deletePrivateMessage,
|
||||
unfollowRemotes,
|
||||
} from './shared';
|
||||
|
||||
let recipient_id: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
await setupLogins();
|
||||
let follow = await followBeta(alpha);
|
||||
recipient_id = follow.community_view.creator.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await unfollowRemotes(alpha);
|
||||
});
|
||||
|
||||
test('Create a private message', async () => {
|
||||
let pmRes = await createPrivateMessage(alpha, recipient_id);
|
||||
expect(pmRes.private_message_view.private_message.content).toBeDefined();
|
||||
expect(pmRes.private_message_view.private_message.local).toBe(true);
|
||||
expect(pmRes.private_message_view.creator.local).toBe(true);
|
||||
expect(pmRes.private_message_view.recipient.local).toBe(false);
|
||||
|
||||
let betaPms = await listPrivateMessages(beta);
|
||||
expect(betaPms.private_messages[0].private_message.content).toBeDefined();
|
||||
expect(betaPms.private_messages[0].private_message.local).toBe(false);
|
||||
expect(betaPms.private_messages[0].creator.local).toBe(false);
|
||||
expect(betaPms.private_messages[0].recipient.local).toBe(true);
|
||||
});
|
||||
|
||||
test('Update a private message', async () => {
|
||||
let updatedContent = 'A jest test federated private message edited';
|
||||
|
||||
let pmRes = await createPrivateMessage(alpha, recipient_id);
|
||||
let pmUpdated = await editPrivateMessage(
|
||||
alpha,
|
||||
pmRes.private_message_view.private_message.id
|
||||
);
|
||||
expect(pmUpdated.private_message_view.private_message.content).toBe(
|
||||
updatedContent
|
||||
);
|
||||
|
||||
let betaPms = await listPrivateMessages(beta);
|
||||
expect(betaPms.private_messages[0].private_message.content).toBe(
|
||||
updatedContent
|
||||
);
|
||||
});
|
||||
|
||||
test('Delete a private message', async () => {
|
||||
let pmRes = await createPrivateMessage(alpha, recipient_id);
|
||||
let betaPms1 = await listPrivateMessages(beta);
|
||||
let deletedPmRes = await deletePrivateMessage(
|
||||
alpha,
|
||||
true,
|
||||
pmRes.private_message_view.private_message.id
|
||||
);
|
||||
expect(deletedPmRes.private_message_view.private_message.deleted).toBe(true);
|
||||
|
||||
// The GetPrivateMessages filters out deleted,
|
||||
// even though they are in the actual database.
|
||||
// no reason to show them
|
||||
let betaPms2 = await listPrivateMessages(beta);
|
||||
expect(betaPms2.private_messages.length).toBe(
|
||||
betaPms1.private_messages.length - 1
|
||||
);
|
||||
|
||||
// Undelete
|
||||
let undeletedPmRes = await deletePrivateMessage(
|
||||
alpha,
|
||||
false,
|
||||
pmRes.private_message_view.private_message.id
|
||||
);
|
||||
expect(undeletedPmRes.private_message_view.private_message.deleted).toBe(
|
||||
false
|
||||
);
|
||||
|
||||
let betaPms3 = await listPrivateMessages(beta);
|
||||
expect(betaPms3.private_messages.length).toBe(
|
||||
betaPms1.private_messages.length
|
||||
);
|
||||
});
|
636
api_tests/src/shared.ts
Normal file
636
api_tests/src/shared.ts
Normal file
|
@ -0,0 +1,636 @@
|
|||
import {
|
||||
Login,
|
||||
LoginResponse,
|
||||
CreatePost,
|
||||
EditPost,
|
||||
CreateComment,
|
||||
DeletePost,
|
||||
RemovePost,
|
||||
StickyPost,
|
||||
LockPost,
|
||||
PostResponse,
|
||||
SearchResponse,
|
||||
FollowCommunity,
|
||||
CommunityResponse,
|
||||
GetFollowedCommunitiesResponse,
|
||||
GetPostResponse,
|
||||
Register,
|
||||
Comment,
|
||||
EditComment,
|
||||
DeleteComment,
|
||||
RemoveComment,
|
||||
Search,
|
||||
CommentResponse,
|
||||
GetCommunity,
|
||||
CreateCommunity,
|
||||
DeleteCommunity,
|
||||
RemoveCommunity,
|
||||
GetPersonMentions,
|
||||
CreateCommentLike,
|
||||
CreatePostLike,
|
||||
EditPrivateMessage,
|
||||
DeletePrivateMessage,
|
||||
GetFollowedCommunities,
|
||||
GetPrivateMessages,
|
||||
GetSite,
|
||||
GetPost,
|
||||
PrivateMessageResponse,
|
||||
PrivateMessagesResponse,
|
||||
GetPersonMentionsResponse,
|
||||
SaveUserSettings,
|
||||
SortType,
|
||||
ListingType,
|
||||
GetSiteResponse,
|
||||
SearchType,
|
||||
LemmyHttp,
|
||||
BanPersonResponse,
|
||||
BanPerson,
|
||||
BanFromCommunity,
|
||||
BanFromCommunityResponse,
|
||||
Post,
|
||||
CreatePrivateMessage,
|
||||
} from 'lemmy-js-client';
|
||||
|
||||
export interface API {
|
||||
client: LemmyHttp;
|
||||
auth?: string;
|
||||
}
|
||||
|
||||
export let alpha: API = {
|
||||
client: new LemmyHttp('http://localhost:8541/api/v2'),
|
||||
};
|
||||
|
||||
export let beta: API = {
|
||||
client: new LemmyHttp('http://localhost:8551/api/v2'),
|
||||
};
|
||||
|
||||
export let gamma: API = {
|
||||
client: new LemmyHttp('http://localhost:8561/api/v2'),
|
||||
};
|
||||
|
||||
export let delta: API = {
|
||||
client: new LemmyHttp('http://localhost:8571/api/v2'),
|
||||
};
|
||||
|
||||
export let epsilon: API = {
|
||||
client: new LemmyHttp('http://localhost:8581/api/v2'),
|
||||
};
|
||||
|
||||
export async function setupLogins() {
|
||||
let formAlpha: Login = {
|
||||
username_or_email: 'lemmy_alpha',
|
||||
password: 'lemmy',
|
||||
};
|
||||
let resAlpha = alpha.client.login(formAlpha);
|
||||
|
||||
let formBeta = {
|
||||
username_or_email: 'lemmy_beta',
|
||||
password: 'lemmy',
|
||||
};
|
||||
let resBeta = beta.client.login(formBeta);
|
||||
|
||||
let formGamma = {
|
||||
username_or_email: 'lemmy_gamma',
|
||||
password: 'lemmy',
|
||||
};
|
||||
let resGamma = gamma.client.login(formGamma);
|
||||
|
||||
let formDelta = {
|
||||
username_or_email: 'lemmy_delta',
|
||||
password: 'lemmy',
|
||||
};
|
||||
let resDelta = delta.client.login(formDelta);
|
||||
|
||||
let formEpsilon = {
|
||||
username_or_email: 'lemmy_epsilon',
|
||||
password: 'lemmy',
|
||||
};
|
||||
let resEpsilon = epsilon.client.login(formEpsilon);
|
||||
|
||||
let res = await Promise.all([
|
||||
resAlpha,
|
||||
resBeta,
|
||||
resGamma,
|
||||
resDelta,
|
||||
resEpsilon,
|
||||
]);
|
||||
|
||||
alpha.auth = res[0].jwt;
|
||||
beta.auth = res[1].jwt;
|
||||
gamma.auth = res[2].jwt;
|
||||
delta.auth = res[3].jwt;
|
||||
epsilon.auth = res[4].jwt;
|
||||
}
|
||||
|
||||
export async function createPost(
|
||||
api: API,
|
||||
community_id: number
|
||||
): Promise<PostResponse> {
|
||||
let name = randomString(5);
|
||||
let body = randomString(10);
|
||||
let url = 'https://google.com/';
|
||||
let form: CreatePost = {
|
||||
name,
|
||||
url,
|
||||
body,
|
||||
auth: api.auth,
|
||||
community_id,
|
||||
nsfw: false,
|
||||
};
|
||||
return api.client.createPost(form);
|
||||
}
|
||||
|
||||
export async function editPost(api: API, post: Post): Promise<PostResponse> {
|
||||
let name = 'A jest test federated post, updated';
|
||||
let form: EditPost = {
|
||||
name,
|
||||
post_id: post.id,
|
||||
auth: api.auth,
|
||||
nsfw: false,
|
||||
};
|
||||
return api.client.editPost(form);
|
||||
}
|
||||
|
||||
export async function deletePost(
|
||||
api: API,
|
||||
deleted: boolean,
|
||||
post: Post
|
||||
): Promise<PostResponse> {
|
||||
let form: DeletePost = {
|
||||
post_id: post.id,
|
||||
deleted: deleted,
|
||||
auth: api.auth,
|
||||
};
|
||||
return api.client.deletePost(form);
|
||||
}
|
||||
|
||||
export async function removePost(
|
||||
api: API,
|
||||
removed: boolean,
|
||||
post: Post
|
||||
): Promise<PostResponse> {
|
||||
let form: RemovePost = {
|
||||
post_id: post.id,
|
||||
removed,
|
||||
auth: api.auth,
|
||||
};
|
||||
return api.client.removePost(form);
|
||||
}
|
||||
|
||||
export async function stickyPost(
|
||||
api: API,
|
||||
stickied: boolean,
|
||||
post: Post
|
||||
): Promise<PostResponse> {
|
||||
let form: StickyPost = {
|
||||
post_id: post.id,
|
||||
stickied,
|
||||
auth: api.auth,
|
||||
};
|
||||
return api.client.stickyPost(form);
|
||||
}
|
||||
|
||||
export async function lockPost(
|
||||
api: API,
|
||||
locked: boolean,
|
||||
post: Post
|
||||
): Promise<PostResponse> {
|
||||
let form: LockPost = {
|
||||
post_id: post.id,
|
||||
locked,
|
||||
auth: api.auth,
|
||||
};
|
||||
return api.client.lockPost(form);
|
||||
}
|
||||
|
||||
export async function searchPost(
|
||||
api: API,
|
||||
post: Post
|
||||
): Promise<SearchResponse> {
|
||||
let form: Search = {
|
||||
q: post.ap_id,
|
||||
type_: SearchType.Posts,
|
||||
sort: SortType.TopAll,
|
||||
};
|
||||
return api.client.search(form);
|
||||
}
|
||||
|
||||
export async function searchPostLocal(
|
||||
api: API,
|
||||
post: Post
|
||||
): Promise<SearchResponse> {
|
||||
let form: Search = {
|
||||
q: post.name,
|
||||
type_: SearchType.Posts,
|
||||
sort: SortType.TopAll,
|
||||
};
|
||||
return api.client.search(form);
|
||||
}
|
||||
|
||||
export async function getPost(
|
||||
api: API,
|
||||
post_id: number
|
||||
): Promise<GetPostResponse> {
|
||||
let form: GetPost = {
|
||||
id: post_id,
|
||||
};
|
||||
return api.client.getPost(form);
|
||||
}
|
||||
|
||||
export async function searchComment(
|
||||
api: API,
|
||||
comment: Comment
|
||||
): Promise<SearchResponse> {
|
||||
let form: Search = {
|
||||
q: comment.ap_id,
|
||||
type_: SearchType.Comments,
|
||||
sort: SortType.TopAll,
|
||||
};
|
||||
return api.client.search(form);
|
||||
}
|
||||
|
||||
export async function searchForBetaCommunity(
|
||||
api: API
|
||||
): Promise<SearchResponse> {
|
||||
// Make sure lemmy-beta/c/main is cached on lemmy_alpha
|
||||
// Use short-hand search url
|
||||
let form: Search = {
|
||||
q: '!main@lemmy-beta:8551',
|
||||
type_: SearchType.Communities,
|
||||
sort: SortType.TopAll,
|
||||
};
|
||||
return api.client.search(form);
|
||||
}
|
||||
|
||||
export async function searchForCommunity(
|
||||
api: API,
|
||||
q: string
|
||||
): Promise<SearchResponse> {
|
||||
// Use short-hand search url
|
||||
let form: Search = {
|
||||
q,
|
||||
type_: SearchType.Communities,
|
||||
sort: SortType.TopAll,
|
||||
};
|
||||
return api.client.search(form);
|
||||
}
|
||||
|
||||
export async function searchForUser(
|
||||
api: API,
|
||||
apShortname: string
|
||||
): Promise<SearchResponse> {
|
||||
// Make sure lemmy-beta/c/main is cached on lemmy_alpha
|
||||
// Use short-hand search url
|
||||
let form: Search = {
|
||||
q: apShortname,
|
||||
type_: SearchType.Users,
|
||||
sort: SortType.TopAll,
|
||||
};
|
||||
return api.client.search(form);
|
||||
}
|
||||
|
||||
export async function banPersonFromSite(
|
||||
api: API,
|
||||
person_id: number,
|
||||
ban: boolean
|
||||
): Promise<BanPersonResponse> {
|
||||
// Make sure lemmy-beta/c/main is cached on lemmy_alpha
|
||||
// Use short-hand search url
|
||||
let form: BanPerson = {
|
||||
person_id,
|
||||
ban,
|
||||
remove_data: false,
|
||||
auth: api.auth,
|
||||
};
|
||||
return api.client.banPerson(form);
|
||||
}
|
||||
|
||||
export async function banPersonFromCommunity(
|
||||
api: API,
|
||||
person_id: number,
|
||||
community_id: number,
|
||||
ban: boolean
|
||||
): Promise<BanFromCommunityResponse> {
|
||||
// Make sure lemmy-beta/c/main is cached on lemmy_alpha
|
||||
// Use short-hand search url
|
||||
let form: BanFromCommunity = {
|
||||
person_id,
|
||||
community_id,
|
||||
remove_data: false,
|
||||
ban,
|
||||
auth: api.auth,
|
||||
};
|
||||
return api.client.banFromCommunity(form);
|
||||
}
|
||||
|
||||
export async function followCommunity(
|
||||
api: API,
|
||||
follow: boolean,
|
||||
community_id: number
|
||||
): Promise<CommunityResponse> {
|
||||
let form: FollowCommunity = {
|
||||
community_id,
|
||||
follow,
|
||||
auth: api.auth,
|
||||
};
|
||||
return api.client.followCommunity(form);
|
||||
}
|
||||
|
||||
export async function checkFollowedCommunities(
|
||||
api: API
|
||||
): Promise<GetFollowedCommunitiesResponse> {
|
||||
let form: GetFollowedCommunities = {
|
||||
auth: api.auth,
|
||||
};
|
||||
return api.client.getFollowedCommunities(form);
|
||||
}
|
||||
|
||||
export async function likePost(
|
||||
api: API,
|
||||
score: number,
|
||||
post: Post
|
||||
): Promise<PostResponse> {
|
||||
let form: CreatePostLike = {
|
||||
post_id: post.id,
|
||||
score: score,
|
||||
auth: api.auth,
|
||||
};
|
||||
|
||||
return api.client.likePost(form);
|
||||
}
|
||||
|
||||
export async function createComment(
|
||||
api: API,
|
||||
post_id: number,
|
||||
parent_id?: number,
|
||||
content = 'a jest test comment'
|
||||
): Promise<CommentResponse> {
|
||||
let form: CreateComment = {
|
||||
content,
|
||||
post_id,
|
||||
parent_id,
|
||||
auth: api.auth,
|
||||
};
|
||||
return api.client.createComment(form);
|
||||
}
|
||||
|
||||
export async function editComment(
|
||||
api: API,
|
||||
comment_id: number,
|
||||
content = 'A jest test federated comment update'
|
||||
): Promise<CommentResponse> {
|
||||
let form: EditComment = {
|
||||
content,
|
||||
comment_id,
|
||||
auth: api.auth,
|
||||
};
|
||||
return api.client.editComment(form);
|
||||
}
|
||||
|
||||
export async function deleteComment(
|
||||
api: API,
|
||||
deleted: boolean,
|
||||
comment_id: number
|
||||
): Promise<CommentResponse> {
|
||||
let form: DeleteComment = {
|
||||
comment_id,
|
||||
deleted,
|
||||
auth: api.auth,
|
||||
};
|
||||
return api.client.deleteComment(form);
|
||||
}
|
||||
|
||||
export async function removeComment(
|
||||
api: API,
|
||||
removed: boolean,
|
||||
comment_id: number
|
||||
): Promise<CommentResponse> {
|
||||
let form: RemoveComment = {
|
||||
comment_id,
|
||||
removed,
|
||||
auth: api.auth,
|
||||
};
|
||||
return api.client.removeComment(form);
|
||||
}
|
||||
|
||||
export async function getMentions(api: API): Promise<GetPersonMentionsResponse> {
|
||||
let form: GetPersonMentions = {
|
||||
sort: SortType.New,
|
||||
unread_only: false,
|
||||
auth: api.auth,
|
||||
};
|
||||
return api.client.getPersonMentions(form);
|
||||
}
|
||||
|
||||
export async function likeComment(
|
||||
api: API,
|
||||
score: number,
|
||||
comment: Comment
|
||||
): Promise<CommentResponse> {
|
||||
let form: CreateCommentLike = {
|
||||
comment_id: comment.id,
|
||||
score,
|
||||
auth: api.auth,
|
||||
};
|
||||
return api.client.likeComment(form);
|
||||
}
|
||||
|
||||
export async function createCommunity(
|
||||
api: API,
|
||||
name_: string = randomString(5)
|
||||
): Promise<CommunityResponse> {
|
||||
let description = 'a sample description';
|
||||
let icon = 'https://image.flaticon.com/icons/png/512/35/35896.png';
|
||||
let banner = 'https://image.flaticon.com/icons/png/512/35/35896.png';
|
||||
let form: CreateCommunity = {
|
||||
name: name_,
|
||||
title: name_,
|
||||
description,
|
||||
icon,
|
||||
banner,
|
||||
nsfw: false,
|
||||
auth: api.auth,
|
||||
};
|
||||
return api.client.createCommunity(form);
|
||||
}
|
||||
|
||||
export async function getCommunity(
|
||||
api: API,
|
||||
id: number
|
||||
): Promise<CommunityResponse> {
|
||||
let form: GetCommunity = {
|
||||
id,
|
||||
};
|
||||
return api.client.getCommunity(form);
|
||||
}
|
||||
|
||||
export async function deleteCommunity(
|
||||
api: API,
|
||||
deleted: boolean,
|
||||
community_id: number
|
||||
): Promise<CommunityResponse> {
|
||||
let form: DeleteCommunity = {
|
||||
community_id,
|
||||
deleted,
|
||||
auth: api.auth,
|
||||
};
|
||||
return api.client.deleteCommunity(form);
|
||||
}
|
||||
|
||||
export async function removeCommunity(
|
||||
api: API,
|
||||
removed: boolean,
|
||||
community_id: number
|
||||
): Promise<CommunityResponse> {
|
||||
let form: RemoveCommunity = {
|
||||
community_id,
|
||||
removed,
|
||||
auth: api.auth,
|
||||
};
|
||||
return api.client.removeCommunity(form);
|
||||
}
|
||||
|
||||
export async function createPrivateMessage(
|
||||
api: API,
|
||||
recipient_id: number
|
||||
): Promise<PrivateMessageResponse> {
|
||||
let content = 'A jest test federated private message';
|
||||
let form: CreatePrivateMessage = {
|
||||
content,
|
||||
recipient_id,
|
||||
auth: api.auth,
|
||||
};
|
||||
return api.client.createPrivateMessage(form);
|
||||
}
|
||||
|
||||
export async function editPrivateMessage(
|
||||
api: API,
|
||||
private_message_id: number
|
||||
): Promise<PrivateMessageResponse> {
|
||||
let updatedContent = 'A jest test federated private message edited';
|
||||
let form: EditPrivateMessage = {
|
||||
content: updatedContent,
|
||||
private_message_id,
|
||||
auth: api.auth,
|
||||
};
|
||||
return api.client.editPrivateMessage(form);
|
||||
}
|
||||
|
||||
export async function deletePrivateMessage(
|
||||
api: API,
|
||||
deleted: boolean,
|
||||
private_message_id: number
|
||||
): Promise<PrivateMessageResponse> {
|
||||
let form: DeletePrivateMessage = {
|
||||
deleted,
|
||||
private_message_id,
|
||||
auth: api.auth,
|
||||
};
|
||||
return api.client.deletePrivateMessage(form);
|
||||
}
|
||||
|
||||
export async function registerUser(
|
||||
api: API,
|
||||
username: string = randomString(5)
|
||||
): Promise<LoginResponse> {
|
||||
let form: Register = {
|
||||
username,
|
||||
password: 'test',
|
||||
password_verify: 'test',
|
||||
show_nsfw: true,
|
||||
};
|
||||
return api.client.register(form);
|
||||
}
|
||||
|
||||
export async function saveUserSettingsBio(
|
||||
api: API,
|
||||
auth: string
|
||||
): Promise<LoginResponse> {
|
||||
let form: SaveUserSettings = {
|
||||
show_nsfw: true,
|
||||
theme: 'darkly',
|
||||
default_sort_type: Object.keys(SortType).indexOf(SortType.Active),
|
||||
default_listing_type: Object.keys(ListingType).indexOf(ListingType.All),
|
||||
lang: 'en',
|
||||
show_avatars: true,
|
||||
send_notifications_to_email: false,
|
||||
bio: 'a changed bio',
|
||||
auth,
|
||||
};
|
||||
return saveUserSettings(api, form);
|
||||
}
|
||||
|
||||
export async function saveUserSettings(
|
||||
api: API,
|
||||
form: SaveUserSettings
|
||||
): Promise<LoginResponse> {
|
||||
return api.client.saveUserSettings(form);
|
||||
}
|
||||
|
||||
export async function getSite(
|
||||
api: API,
|
||||
auth: string
|
||||
): Promise<GetSiteResponse> {
|
||||
let form: GetSite = {
|
||||
auth,
|
||||
};
|
||||
return api.client.getSite(form);
|
||||
}
|
||||
|
||||
export async function listPrivateMessages(
|
||||
api: API
|
||||
): Promise<PrivateMessagesResponse> {
|
||||
let form: GetPrivateMessages = {
|
||||
auth: api.auth,
|
||||
unread_only: false,
|
||||
limit: 999,
|
||||
};
|
||||
return api.client.getPrivateMessages(form);
|
||||
}
|
||||
|
||||
export async function unfollowRemotes(
|
||||
api: API
|
||||
): Promise<GetFollowedCommunitiesResponse> {
|
||||
// Unfollow all remote communities
|
||||
let followed = await checkFollowedCommunities(api);
|
||||
let remoteFollowed = followed.communities.filter(
|
||||
c => c.community.local == false
|
||||
);
|
||||
for (let cu of remoteFollowed) {
|
||||
await followCommunity(api, false, cu.community.id);
|
||||
}
|
||||
let followed2 = await checkFollowedCommunities(api);
|
||||
return followed2;
|
||||
}
|
||||
|
||||
export async function followBeta(api: API): Promise<CommunityResponse> {
|
||||
// Cache it
|
||||
let search = await searchForBetaCommunity(api);
|
||||
let com = search.communities.find(c => c.community.local == false);
|
||||
if (com) {
|
||||
let follow = await followCommunity(api, true, com.community.id);
|
||||
return follow;
|
||||
}
|
||||
}
|
||||
|
||||
export function delay(millis: number = 500) {
|
||||
return new Promise(resolve => setTimeout(resolve, millis));
|
||||
}
|
||||
|
||||
export function longDelay() {
|
||||
return delay(10000);
|
||||
}
|
||||
|
||||
export function wrapper(form: any): string {
|
||||
return JSON.stringify(form);
|
||||
}
|
||||
|
||||
function randomString(length: number): string {
|
||||
var result = '';
|
||||
var characters = 'abcdefghijklmnopqrstuvwxyz0123456789_';
|
||||
var charactersLength = characters.length;
|
||||
for (var i = 0; i < length; i++) {
|
||||
result += characters.charAt(Math.floor(Math.random() * charactersLength));
|
||||
}
|
||||
return result;
|
||||
}
|
65
api_tests/src/user.spec.ts
Normal file
65
api_tests/src/user.spec.ts
Normal file
|
@ -0,0 +1,65 @@
|
|||
jest.setTimeout(120000);
|
||||
import {
|
||||
alpha,
|
||||
beta,
|
||||
registerUser,
|
||||
searchForUser,
|
||||
saveUserSettings,
|
||||
getSite,
|
||||
} from './shared';
|
||||
import {
|
||||
PersonViewSafe,
|
||||
SaveUserSettings,
|
||||
SortType,
|
||||
ListingType,
|
||||
} from 'lemmy-js-client';
|
||||
|
||||
let auth: string;
|
||||
let apShortname: string;
|
||||
|
||||
function assertUserFederation(userOne: PersonViewSafe, userTwo: PersonViewSafe) {
|
||||
expect(userOne.person.name).toBe(userTwo.person.name);
|
||||
expect(userOne.person.preferred_username).toBe(userTwo.person.preferred_username);
|
||||
expect(userOne.person.bio).toBe(userTwo.person.bio);
|
||||
expect(userOne.person.actor_id).toBe(userTwo.person.actor_id);
|
||||
expect(userOne.person.avatar).toBe(userTwo.person.avatar);
|
||||
expect(userOne.person.banner).toBe(userTwo.person.banner);
|
||||
expect(userOne.person.published).toBe(userTwo.person.published);
|
||||
}
|
||||
|
||||
test('Create user', async () => {
|
||||
let userRes = await registerUser(alpha);
|
||||
expect(userRes.jwt).toBeDefined();
|
||||
auth = userRes.jwt;
|
||||
|
||||
let site = await getSite(alpha, auth);
|
||||
expect(site.my_user).toBeDefined();
|
||||
apShortname = `@${site.my_user.person.name}@lemmy-alpha:8541`;
|
||||
});
|
||||
|
||||
test('Set some user settings, check that they are federated', async () => {
|
||||
let avatar = 'https://image.flaticon.com/icons/png/512/35/35896.png';
|
||||
let banner = 'https://image.flaticon.com/icons/png/512/36/35896.png';
|
||||
let bio = 'a changed bio';
|
||||
let form: SaveUserSettings = {
|
||||
show_nsfw: false,
|
||||
theme: '',
|
||||
default_sort_type: Object.keys(SortType).indexOf(SortType.Hot),
|
||||
default_listing_type: Object.keys(ListingType).indexOf(ListingType.All),
|
||||
lang: '',
|
||||
avatar,
|
||||
banner,
|
||||
preferred_username: 'user321',
|
||||
show_avatars: false,
|
||||
send_notifications_to_email: false,
|
||||
bio,
|
||||
auth,
|
||||
};
|
||||
await saveUserSettings(alpha, form);
|
||||
|
||||
let searchAlpha = await searchForUser(alpha, apShortname);
|
||||
let userOnAlpha = searchAlpha.users[0];
|
||||
let searchBeta = await searchForUser(beta, apShortname);
|
||||
let userOnBeta = searchBeta.users[0];
|
||||
assertUserFederation(userOnAlpha, userOnBeta);
|
||||
});
|
16
api_tests/tsconfig.json
Normal file
16
api_tests/tsconfig.json
Normal file
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"declarationDir": "./dist",
|
||||
"module": "CommonJS",
|
||||
"noImplicitAny": true,
|
||||
"lib": ["es2017", "es7", "es6", "dom"],
|
||||
"outDir": "./dist",
|
||||
"target": "ES5",
|
||||
"moduleResolution": "Node"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
4951
api_tests/yarn.lock
Normal file
4951
api_tests/yarn.lock
Normal file
File diff suppressed because it is too large
Load diff
3
config/config.hjson
Normal file
3
config/config.hjson
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
hostname: "localhost:8536"
|
||||
}
|
91
config/defaults.hjson
Normal file
91
config/defaults.hjson
Normal file
|
@ -0,0 +1,91 @@
|
|||
{
|
||||
# # optional: parameters for automatic configuration of new instance (only used at first start)
|
||||
# setup: {
|
||||
# # username for the admin user
|
||||
# admin_username: ""
|
||||
# # password for the admin user
|
||||
# admin_password: ""
|
||||
# # optional: email for the admin user (can be omitted and set later through the website)
|
||||
# admin_email: ""
|
||||
# # name of the site (can be changed later)
|
||||
# site_name: ""
|
||||
# }
|
||||
# settings related to the postgresql database
|
||||
database: {
|
||||
# username to connect to postgres
|
||||
user: "lemmy"
|
||||
# password to connect to postgres
|
||||
password: "password"
|
||||
# host where postgres is running
|
||||
host: "localhost"
|
||||
# port where postgres can be accessed
|
||||
port: 5432
|
||||
# name of the postgres database for lemmy
|
||||
database: "lemmy"
|
||||
# maximum number of active sql connections
|
||||
pool_size: 5
|
||||
}
|
||||
# the domain name of your instance (eg "lemmy.ml")
|
||||
hostname: null
|
||||
# address where lemmy should listen for incoming requests
|
||||
bind: "0.0.0.0"
|
||||
# port where lemmy should listen for incoming requests
|
||||
port: 8536
|
||||
# whether tls is required for activitypub. only disable this for debugging, never for producion.
|
||||
tls_enabled: true
|
||||
# json web token for authorization between server and client
|
||||
jwt_secret: "changeme"
|
||||
# address where pictrs is available
|
||||
pictrs_url: "http://pictrs:8080"
|
||||
# address where iframely is available
|
||||
iframely_url: "http://iframely"
|
||||
# rate limits for various user actions, by user ip
|
||||
rate_limit: {
|
||||
# maximum number of messages created in interval
|
||||
message: 180
|
||||
# interval length for message limit
|
||||
message_per_second: 60
|
||||
# maximum number of posts created in interval
|
||||
post: 6
|
||||
# interval length for post limit
|
||||
post_per_second: 600
|
||||
# maximum number of registrations in interval
|
||||
register: 3
|
||||
# interval length for registration limit
|
||||
register_per_second: 3600
|
||||
# maximum number of image uploads in interval
|
||||
image: 6
|
||||
# interval length for image uploads
|
||||
image_per_second: 3600
|
||||
}
|
||||
# settings related to activitypub federation
|
||||
federation: {
|
||||
# whether to enable activitypub federation.
|
||||
enabled: false
|
||||
# Allows and blocks are described here:
|
||||
# https://join.lemmy.ml/docs/en/federation/administration.html#instance-allowlist-and-blocklist
|
||||
#
|
||||
# comma separated list of instances with which federation is allowed
|
||||
# Only one of these blocks should be uncommented
|
||||
# allowed_instances: ["instance1.tld","instance2.tld"]
|
||||
# comma separated list of instances which are blocked from federating
|
||||
# blocked_instances: []
|
||||
}
|
||||
captcha: {
|
||||
enabled: true
|
||||
difficulty: medium # Can be easy, medium, or hard
|
||||
}
|
||||
# # email sending configuration
|
||||
# email: {
|
||||
# # hostname and port of the smtp server
|
||||
# smtp_server: ""
|
||||
# # login name for smtp server
|
||||
# smtp_login: ""
|
||||
# # password to login to the smtp server
|
||||
# smtp_password: ""
|
||||
# # address to send emails from, eg "noreply@your-instance.com"
|
||||
# smtp_from_address: ""
|
||||
# # whether or not smtp connections should use tls
|
||||
# use_tls: true
|
||||
# }
|
||||
}
|
50
crates/api/Cargo.toml
Normal file
50
crates/api/Cargo.toml
Normal file
|
@ -0,0 +1,50 @@
|
|||
[package]
|
||||
name = "lemmy_api"
|
||||
version = "0.1.0"
|
||||
edition = "2018"
|
||||
|
||||
[lib]
|
||||
name = "lemmy_api"
|
||||
path = "src/lib.rs"
|
||||
doctest = false
|
||||
|
||||
[dependencies]
|
||||
lemmy_apub = { path = "../apub" }
|
||||
lemmy_utils = { path = "../utils" }
|
||||
lemmy_db_queries = { path = "../db_queries" }
|
||||
lemmy_db_schema = { path = "../db_schema" }
|
||||
lemmy_db_views = { path = "../db_views" }
|
||||
lemmy_db_views_moderator = { path = "../db_views_moderator" }
|
||||
lemmy_db_views_actor = { path = "../db_views_actor" }
|
||||
lemmy_api_structs = { path = "../api_structs" }
|
||||
lemmy_websocket = { path = "../websocket" }
|
||||
diesel = "1.4.5"
|
||||
bcrypt = "0.9.0"
|
||||
chrono = { version = "0.4.19", features = ["serde"] }
|
||||
serde_json = { version = "1.0.61", features = ["preserve_order"] }
|
||||
serde = { version = "1.0.123", features = ["derive"] }
|
||||
actix = "0.10.0"
|
||||
actix-web = { version = "3.3.2", default-features = false }
|
||||
actix-rt = { version = "1.1.1", default-features = false }
|
||||
awc = { version = "2.0.3", default-features = false }
|
||||
log = "0.4.14"
|
||||
rand = "0.8.3"
|
||||
strum = "0.20.0"
|
||||
strum_macros = "0.20.1"
|
||||
lazy_static = "1.4.0"
|
||||
url = { version = "2.2.1", features = ["serde"] }
|
||||
openssl = "0.10.32"
|
||||
http = "0.2.3"
|
||||
http-signature-normalization-actix = { version = "0.4.1", default-features = false, features = ["sha-2"] }
|
||||
base64 = "0.13.0"
|
||||
tokio = "0.3.6"
|
||||
futures = "0.3.12"
|
||||
itertools = "0.10.0"
|
||||
uuid = { version = "0.8.2", features = ["serde", "v4"] }
|
||||
sha2 = "0.9.3"
|
||||
async-trait = "0.1.42"
|
||||
captcha = "0.0.8"
|
||||
anyhow = "1.0.38"
|
||||
thiserror = "1.0.23"
|
||||
background-jobs = "0.8.0"
|
||||
reqwest = { version = "0.10.10", features = ["json"] }
|
876
crates/api/src/comment.rs
Normal file
876
crates/api/src/comment.rs
Normal file
|
@ -0,0 +1,876 @@
|
|||
use crate::{
|
||||
check_community_ban,
|
||||
check_downvotes_enabled,
|
||||
collect_moderated_communities,
|
||||
get_local_user_view_from_jwt,
|
||||
get_local_user_view_from_jwt_opt,
|
||||
get_post,
|
||||
is_mod_or_admin,
|
||||
Perform,
|
||||
};
|
||||
use actix_web::web::Data;
|
||||
use lemmy_api_structs::{blocking, comment::*, send_local_notifs};
|
||||
use lemmy_apub::{generate_apub_endpoint, ApubLikeableType, ApubObjectType, EndpointType};
|
||||
use lemmy_db_queries::{
|
||||
source::comment::Comment_,
|
||||
Crud,
|
||||
Likeable,
|
||||
ListingType,
|
||||
Reportable,
|
||||
Saveable,
|
||||
SortType,
|
||||
};
|
||||
use lemmy_db_schema::{
|
||||
source::{comment::*, comment_report::*, moderator::*},
|
||||
LocalUserId,
|
||||
};
|
||||
use lemmy_db_views::{
|
||||
comment_report_view::{CommentReportQueryBuilder, CommentReportView},
|
||||
comment_view::{CommentQueryBuilder, CommentView},
|
||||
local_user_view::LocalUserView,
|
||||
};
|
||||
use lemmy_utils::{
|
||||
utils::{remove_slurs, scrape_text_for_mentions},
|
||||
ApiError,
|
||||
ConnectionId,
|
||||
LemmyError,
|
||||
};
|
||||
use lemmy_websocket::{
|
||||
messages::{SendComment, SendModRoomMessage, SendUserRoomMessage},
|
||||
LemmyContext,
|
||||
UserOperation,
|
||||
};
|
||||
use std::str::FromStr;
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl Perform for CreateComment {
|
||||
type Response = CommentResponse;
|
||||
|
||||
async fn perform(
|
||||
&self,
|
||||
context: &Data<LemmyContext>,
|
||||
websocket_id: Option<ConnectionId>,
|
||||
) -> Result<CommentResponse, LemmyError> {
|
||||
let data: &CreateComment = &self;
|
||||
let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
|
||||
|
||||
let content_slurs_removed = remove_slurs(&data.content.to_owned());
|
||||
|
||||
// Check for a community ban
|
||||
let post_id = data.post_id;
|
||||
let post = get_post(post_id, context.pool()).await?;
|
||||
|
||||
check_community_ban(local_user_view.person.id, post.community_id, context.pool()).await?;
|
||||
|
||||
// Check if post is locked, no new comments
|
||||
if post.locked {
|
||||
return Err(ApiError::err("locked").into());
|
||||
}
|
||||
|
||||
// If there's a parent_id, check to make sure that comment is in that post
|
||||
if let Some(parent_id) = data.parent_id {
|
||||
// Make sure the parent comment exists
|
||||
let parent =
|
||||
match blocking(context.pool(), move |conn| Comment::read(&conn, parent_id)).await? {
|
||||
Ok(comment) => comment,
|
||||
Err(_e) => return Err(ApiError::err("couldnt_create_comment").into()),
|
||||
};
|
||||
if parent.post_id != post_id {
|
||||
return Err(ApiError::err("couldnt_create_comment").into());
|
||||
}
|
||||
}
|
||||
|
||||
let comment_form = CommentForm {
|
||||
content: content_slurs_removed,
|
||||
parent_id: data.parent_id.to_owned(),
|
||||
post_id: data.post_id,
|
||||
creator_id: local_user_view.person.id,
|
||||
removed: None,
|
||||
deleted: None,
|
||||
read: None,
|
||||
published: None,
|
||||
updated: None,
|
||||
ap_id: None,
|
||||
local: true,
|
||||
};
|
||||
|
||||
// Create the comment
|
||||
let comment_form2 = comment_form.clone();
|
||||
let inserted_comment = match blocking(context.pool(), move |conn| {
|
||||
Comment::create(&conn, &comment_form2)
|
||||
})
|
||||
.await?
|
||||
{
|
||||
Ok(comment) => comment,
|
||||
Err(_e) => return Err(ApiError::err("couldnt_create_comment").into()),
|
||||
};
|
||||
|
||||
// Necessary to update the ap_id
|
||||
let inserted_comment_id = inserted_comment.id;
|
||||
let updated_comment: Comment =
|
||||
match blocking(context.pool(), move |conn| -> Result<Comment, LemmyError> {
|
||||
let apub_id =
|
||||
generate_apub_endpoint(EndpointType::Comment, &inserted_comment_id.to_string())?;
|
||||
Ok(Comment::update_ap_id(&conn, inserted_comment_id, apub_id)?)
|
||||
})
|
||||
.await?
|
||||
{
|
||||
Ok(comment) => comment,
|
||||
Err(_e) => return Err(ApiError::err("couldnt_create_comment").into()),
|
||||
};
|
||||
|
||||
updated_comment
|
||||
.send_create(&local_user_view.person, context)
|
||||
.await?;
|
||||
|
||||
// Scan the comment for user mentions, add those rows
|
||||
let post_id = post.id;
|
||||
let mentions = scrape_text_for_mentions(&comment_form.content);
|
||||
let recipient_ids = send_local_notifs(
|
||||
mentions,
|
||||
updated_comment.clone(),
|
||||
local_user_view.person.clone(),
|
||||
post,
|
||||
context.pool(),
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// You like your own comment by default
|
||||
let like_form = CommentLikeForm {
|
||||
comment_id: inserted_comment.id,
|
||||
post_id,
|
||||
person_id: local_user_view.person.id,
|
||||
score: 1,
|
||||
};
|
||||
|
||||
let like = move |conn: &'_ _| CommentLike::like(&conn, &like_form);
|
||||
if blocking(context.pool(), like).await?.is_err() {
|
||||
return Err(ApiError::err("couldnt_like_comment").into());
|
||||
}
|
||||
|
||||
updated_comment
|
||||
.send_like(&local_user_view.person, context)
|
||||
.await?;
|
||||
|
||||
let person_id = local_user_view.person.id;
|
||||
let mut comment_view = blocking(context.pool(), move |conn| {
|
||||
CommentView::read(&conn, inserted_comment.id, Some(person_id))
|
||||
})
|
||||
.await??;
|
||||
|
||||
// If its a comment to yourself, mark it as read
|
||||
let comment_id = comment_view.comment.id;
|
||||
if local_user_view.person.id == comment_view.get_recipient_id() {
|
||||
match blocking(context.pool(), move |conn| {
|
||||
Comment::update_read(conn, comment_id, true)
|
||||
})
|
||||
.await?
|
||||
{
|
||||
Ok(comment) => comment,
|
||||
Err(_e) => return Err(ApiError::err("couldnt_update_comment").into()),
|
||||
};
|
||||
comment_view.comment.read = true;
|
||||
}
|
||||
|
||||
let mut res = CommentResponse {
|
||||
comment_view,
|
||||
recipient_ids,
|
||||
form_id: data.form_id.to_owned(),
|
||||
};
|
||||
|
||||
context.chat_server().do_send(SendComment {
|
||||
op: UserOperation::CreateComment,
|
||||
comment: res.clone(),
|
||||
websocket_id,
|
||||
});
|
||||
|
||||
res.recipient_ids = Vec::new(); // Necessary to avoid doubles
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl Perform for EditComment {
|
||||
type Response = CommentResponse;
|
||||
|
||||
async fn perform(
|
||||
&self,
|
||||
context: &Data<LemmyContext>,
|
||||
websocket_id: Option<ConnectionId>,
|
||||
) -> Result<CommentResponse, LemmyError> {
|
||||
let data: &EditComment = &self;
|
||||
let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
|
||||
|
||||
let comment_id = data.comment_id;
|
||||
let orig_comment = blocking(context.pool(), move |conn| {
|
||||
CommentView::read(&conn, comment_id, None)
|
||||
})
|
||||
.await??;
|
||||
|
||||
check_community_ban(
|
||||
local_user_view.person.id,
|
||||
orig_comment.community.id,
|
||||
context.pool(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Verify that only the creator can edit
|
||||
if local_user_view.person.id != orig_comment.creator.id {
|
||||
return Err(ApiError::err("no_comment_edit_allowed").into());
|
||||
}
|
||||
|
||||
// Do the update
|
||||
let content_slurs_removed = remove_slurs(&data.content.to_owned());
|
||||
let comment_id = data.comment_id;
|
||||
let updated_comment = match blocking(context.pool(), move |conn| {
|
||||
Comment::update_content(conn, comment_id, &content_slurs_removed)
|
||||
})
|
||||
.await?
|
||||
{
|
||||
Ok(comment) => comment,
|
||||
Err(_e) => return Err(ApiError::err("couldnt_update_comment").into()),
|
||||
};
|
||||
|
||||
// Send the apub update
|
||||
updated_comment
|
||||
.send_update(&local_user_view.person, context)
|
||||
.await?;
|
||||
|
||||
// Do the mentions / recipients
|
||||
let updated_comment_content = updated_comment.content.to_owned();
|
||||
let mentions = scrape_text_for_mentions(&updated_comment_content);
|
||||
let recipient_ids = send_local_notifs(
|
||||
mentions,
|
||||
updated_comment,
|
||||
local_user_view.person.clone(),
|
||||
orig_comment.post,
|
||||
context.pool(),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let comment_id = data.comment_id;
|
||||
let person_id = local_user_view.person.id;
|
||||
let comment_view = blocking(context.pool(), move |conn| {
|
||||
CommentView::read(conn, comment_id, Some(person_id))
|
||||
})
|
||||
.await??;
|
||||
|
||||
let res = CommentResponse {
|
||||
comment_view,
|
||||
recipient_ids,
|
||||
form_id: data.form_id.to_owned(),
|
||||
};
|
||||
|
||||
context.chat_server().do_send(SendComment {
|
||||
op: UserOperation::EditComment,
|
||||
comment: res.clone(),
|
||||
websocket_id,
|
||||
});
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl Perform for DeleteComment {
|
||||
type Response = CommentResponse;
|
||||
|
||||
async fn perform(
|
||||
&self,
|
||||
context: &Data<LemmyContext>,
|
||||
websocket_id: Option<ConnectionId>,
|
||||
) -> Result<CommentResponse, LemmyError> {
|
||||
let data: &DeleteComment = &self;
|
||||
let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
|
||||
|
||||
let comment_id = data.comment_id;
|
||||
let orig_comment = blocking(context.pool(), move |conn| {
|
||||
CommentView::read(&conn, comment_id, None)
|
||||
})
|
||||
.await??;
|
||||
|
||||
check_community_ban(
|
||||
local_user_view.person.id,
|
||||
orig_comment.community.id,
|
||||
context.pool(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Verify that only the creator can delete
|
||||
if local_user_view.person.id != orig_comment.creator.id {
|
||||
return Err(ApiError::err("no_comment_edit_allowed").into());
|
||||
}
|
||||
|
||||
// Do the delete
|
||||
let deleted = data.deleted;
|
||||
let updated_comment = match blocking(context.pool(), move |conn| {
|
||||
Comment::update_deleted(conn, comment_id, deleted)
|
||||
})
|
||||
.await?
|
||||
{
|
||||
Ok(comment) => comment,
|
||||
Err(_e) => return Err(ApiError::err("couldnt_update_comment").into()),
|
||||
};
|
||||
|
||||
// Send the apub message
|
||||
if deleted {
|
||||
updated_comment
|
||||
.send_delete(&local_user_view.person, context)
|
||||
.await?;
|
||||
} else {
|
||||
updated_comment
|
||||
.send_undo_delete(&local_user_view.person, context)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Refetch it
|
||||
let comment_id = data.comment_id;
|
||||
let person_id = local_user_view.person.id;
|
||||
let comment_view = blocking(context.pool(), move |conn| {
|
||||
CommentView::read(conn, comment_id, Some(person_id))
|
||||
})
|
||||
.await??;
|
||||
|
||||
// Build the recipients
|
||||
let comment_view_2 = comment_view.clone();
|
||||
let mentions = vec![];
|
||||
let recipient_ids = send_local_notifs(
|
||||
mentions,
|
||||
updated_comment,
|
||||
local_user_view.person.clone(),
|
||||
comment_view_2.post,
|
||||
context.pool(),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let res = CommentResponse {
|
||||
comment_view,
|
||||
recipient_ids,
|
||||
form_id: None, // TODO a comment delete might clear forms?
|
||||
};
|
||||
|
||||
context.chat_server().do_send(SendComment {
|
||||
op: UserOperation::DeleteComment,
|
||||
comment: res.clone(),
|
||||
websocket_id,
|
||||
});
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl Perform for RemoveComment {
|
||||
type Response = CommentResponse;
|
||||
|
||||
async fn perform(
|
||||
&self,
|
||||
context: &Data<LemmyContext>,
|
||||
websocket_id: Option<ConnectionId>,
|
||||
) -> Result<CommentResponse, LemmyError> {
|
||||
let data: &RemoveComment = &self;
|
||||
let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
|
||||
|
||||
let comment_id = data.comment_id;
|
||||
let orig_comment = blocking(context.pool(), move |conn| {
|
||||
CommentView::read(&conn, comment_id, None)
|
||||
})
|
||||
.await??;
|
||||
|
||||
check_community_ban(
|
||||
local_user_view.person.id,
|
||||
orig_comment.community.id,
|
||||
context.pool(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Verify that only a mod or admin can remove
|
||||
is_mod_or_admin(
|
||||
context.pool(),
|
||||
local_user_view.person.id,
|
||||
orig_comment.community.id,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Do the remove
|
||||
let removed = data.removed;
|
||||
let updated_comment = match blocking(context.pool(), move |conn| {
|
||||
Comment::update_removed(conn, comment_id, removed)
|
||||
})
|
||||
.await?
|
||||
{
|
||||
Ok(comment) => comment,
|
||||
Err(_e) => return Err(ApiError::err("couldnt_update_comment").into()),
|
||||
};
|
||||
|
||||
// Mod tables
|
||||
let form = ModRemoveCommentForm {
|
||||
mod_person_id: local_user_view.person.id,
|
||||
comment_id: data.comment_id,
|
||||
removed: Some(removed),
|
||||
reason: data.reason.to_owned(),
|
||||
};
|
||||
blocking(context.pool(), move |conn| {
|
||||
ModRemoveComment::create(conn, &form)
|
||||
})
|
||||
.await??;
|
||||
|
||||
// Send the apub message
|
||||
if removed {
|
||||
updated_comment
|
||||
.send_remove(&local_user_view.person, context)
|
||||
.await?;
|
||||
} else {
|
||||
updated_comment
|
||||
.send_undo_remove(&local_user_view.person, context)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Refetch it
|
||||
let comment_id = data.comment_id;
|
||||
let person_id = local_user_view.person.id;
|
||||
let comment_view = blocking(context.pool(), move |conn| {
|
||||
CommentView::read(conn, comment_id, Some(person_id))
|
||||
})
|
||||
.await??;
|
||||
|
||||
// Build the recipients
|
||||
let comment_view_2 = comment_view.clone();
|
||||
|
||||
let mentions = vec![];
|
||||
let recipient_ids = send_local_notifs(
|
||||
mentions,
|
||||
updated_comment,
|
||||
local_user_view.person.clone(),
|
||||
comment_view_2.post,
|
||||
context.pool(),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let res = CommentResponse {
|
||||
comment_view,
|
||||
recipient_ids,
|
||||
form_id: None, // TODO maybe this might clear other forms
|
||||
};
|
||||
|
||||
context.chat_server().do_send(SendComment {
|
||||
op: UserOperation::RemoveComment,
|
||||
comment: res.clone(),
|
||||
websocket_id,
|
||||
});
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl Perform for MarkCommentAsRead {
|
||||
type Response = CommentResponse;
|
||||
|
||||
async fn perform(
|
||||
&self,
|
||||
context: &Data<LemmyContext>,
|
||||
_websocket_id: Option<ConnectionId>,
|
||||
) -> Result<CommentResponse, LemmyError> {
|
||||
let data: &MarkCommentAsRead = &self;
|
||||
let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
|
||||
|
||||
let comment_id = data.comment_id;
|
||||
let orig_comment = blocking(context.pool(), move |conn| {
|
||||
CommentView::read(&conn, comment_id, None)
|
||||
})
|
||||
.await??;
|
||||
|
||||
check_community_ban(
|
||||
local_user_view.person.id,
|
||||
orig_comment.community.id,
|
||||
context.pool(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Verify that only the recipient can mark as read
|
||||
if local_user_view.person.id != orig_comment.get_recipient_id() {
|
||||
return Err(ApiError::err("no_comment_edit_allowed").into());
|
||||
}
|
||||
|
||||
// Do the mark as read
|
||||
let read = data.read;
|
||||
match blocking(context.pool(), move |conn| {
|
||||
Comment::update_read(conn, comment_id, read)
|
||||
})
|
||||
.await?
|
||||
{
|
||||
Ok(comment) => comment,
|
||||
Err(_e) => return Err(ApiError::err("couldnt_update_comment").into()),
|
||||
};
|
||||
|
||||
// Refetch it
|
||||
let comment_id = data.comment_id;
|
||||
let person_id = local_user_view.person.id;
|
||||
let comment_view = blocking(context.pool(), move |conn| {
|
||||
CommentView::read(conn, comment_id, Some(person_id))
|
||||
})
|
||||
.await??;
|
||||
|
||||
let res = CommentResponse {
|
||||
comment_view,
|
||||
recipient_ids: Vec::new(),
|
||||
form_id: None,
|
||||
};
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl Perform for SaveComment {
|
||||
type Response = CommentResponse;
|
||||
|
||||
async fn perform(
|
||||
&self,
|
||||
context: &Data<LemmyContext>,
|
||||
_websocket_id: Option<ConnectionId>,
|
||||
) -> Result<CommentResponse, LemmyError> {
|
||||
let data: &SaveComment = &self;
|
||||
let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
|
||||
|
||||
let comment_saved_form = CommentSavedForm {
|
||||
comment_id: data.comment_id,
|
||||
person_id: local_user_view.person.id,
|
||||
};
|
||||
|
||||
if data.save {
|
||||
let save_comment = move |conn: &'_ _| CommentSaved::save(conn, &comment_saved_form);
|
||||
if blocking(context.pool(), save_comment).await?.is_err() {
|
||||
return Err(ApiError::err("couldnt_save_comment").into());
|
||||
}
|
||||
} else {
|
||||
let unsave_comment = move |conn: &'_ _| CommentSaved::unsave(conn, &comment_saved_form);
|
||||
if blocking(context.pool(), unsave_comment).await?.is_err() {
|
||||
return Err(ApiError::err("couldnt_save_comment").into());
|
||||
}
|
||||
}
|
||||
|
||||
let comment_id = data.comment_id;
|
||||
let person_id = local_user_view.person.id;
|
||||
let comment_view = blocking(context.pool(), move |conn| {
|
||||
CommentView::read(conn, comment_id, Some(person_id))
|
||||
})
|
||||
.await??;
|
||||
|
||||
Ok(CommentResponse {
|
||||
comment_view,
|
||||
recipient_ids: Vec::new(),
|
||||
form_id: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl Perform for CreateCommentLike {
|
||||
type Response = CommentResponse;
|
||||
|
||||
async fn perform(
|
||||
&self,
|
||||
context: &Data<LemmyContext>,
|
||||
websocket_id: Option<ConnectionId>,
|
||||
) -> Result<CommentResponse, LemmyError> {
|
||||
let data: &CreateCommentLike = &self;
|
||||
let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
|
||||
|
||||
let mut recipient_ids = Vec::<LocalUserId>::new();
|
||||
|
||||
// Don't do a downvote if site has downvotes disabled
|
||||
check_downvotes_enabled(data.score, context.pool()).await?;
|
||||
|
||||
let comment_id = data.comment_id;
|
||||
let orig_comment = blocking(context.pool(), move |conn| {
|
||||
CommentView::read(&conn, comment_id, None)
|
||||
})
|
||||
.await??;
|
||||
|
||||
check_community_ban(
|
||||
local_user_view.person.id,
|
||||
orig_comment.community.id,
|
||||
context.pool(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Add parent user to recipients
|
||||
let recipient_id = orig_comment.get_recipient_id();
|
||||
if let Ok(local_recipient) = blocking(context.pool(), move |conn| {
|
||||
LocalUserView::read_person(conn, recipient_id)
|
||||
})
|
||||
.await?
|
||||
{
|
||||
recipient_ids.push(local_recipient.local_user.id);
|
||||
}
|
||||
|
||||
let like_form = CommentLikeForm {
|
||||
comment_id: data.comment_id,
|
||||
post_id: orig_comment.post.id,
|
||||
person_id: local_user_view.person.id,
|
||||
score: data.score,
|
||||
};
|
||||
|
||||
// Remove any likes first
|
||||
let person_id = local_user_view.person.id;
|
||||
blocking(context.pool(), move |conn| {
|
||||
CommentLike::remove(conn, person_id, comment_id)
|
||||
})
|
||||
.await??;
|
||||
|
||||
// Only add the like if the score isnt 0
|
||||
let comment = orig_comment.comment;
|
||||
let do_add = like_form.score != 0 && (like_form.score == 1 || like_form.score == -1);
|
||||
if do_add {
|
||||
let like_form2 = like_form.clone();
|
||||
let like = move |conn: &'_ _| CommentLike::like(conn, &like_form2);
|
||||
if blocking(context.pool(), like).await?.is_err() {
|
||||
return Err(ApiError::err("couldnt_like_comment").into());
|
||||
}
|
||||
|
||||
if like_form.score == 1 {
|
||||
comment.send_like(&local_user_view.person, context).await?;
|
||||
} else if like_form.score == -1 {
|
||||
comment
|
||||
.send_dislike(&local_user_view.person, context)
|
||||
.await?;
|
||||
}
|
||||
} else {
|
||||
comment
|
||||
.send_undo_like(&local_user_view.person, context)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Have to refetch the comment to get the current state
|
||||
let comment_id = data.comment_id;
|
||||
let person_id = local_user_view.person.id;
|
||||
let liked_comment = blocking(context.pool(), move |conn| {
|
||||
CommentView::read(conn, comment_id, Some(person_id))
|
||||
})
|
||||
.await??;
|
||||
|
||||
let res = CommentResponse {
|
||||
comment_view: liked_comment,
|
||||
recipient_ids,
|
||||
form_id: None,
|
||||
};
|
||||
|
||||
context.chat_server().do_send(SendComment {
|
||||
op: UserOperation::CreateCommentLike,
|
||||
comment: res.clone(),
|
||||
websocket_id,
|
||||
});
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl Perform for GetComments {
|
||||
type Response = GetCommentsResponse;
|
||||
|
||||
async fn perform(
|
||||
&self,
|
||||
context: &Data<LemmyContext>,
|
||||
_websocket_id: Option<ConnectionId>,
|
||||
) -> Result<GetCommentsResponse, LemmyError> {
|
||||
let data: &GetComments = &self;
|
||||
let local_user_view = get_local_user_view_from_jwt_opt(&data.auth, context.pool()).await?;
|
||||
let person_id = local_user_view.map(|u| u.person.id);
|
||||
|
||||
let type_ = ListingType::from_str(&data.type_)?;
|
||||
let sort = SortType::from_str(&data.sort)?;
|
||||
|
||||
let community_id = data.community_id;
|
||||
let community_name = data.community_name.to_owned();
|
||||
let page = data.page;
|
||||
let limit = data.limit;
|
||||
let comments = blocking(context.pool(), move |conn| {
|
||||
CommentQueryBuilder::create(conn)
|
||||
.listing_type(type_)
|
||||
.sort(&sort)
|
||||
.community_id(community_id)
|
||||
.community_name(community_name)
|
||||
.my_person_id(person_id)
|
||||
.page(page)
|
||||
.limit(limit)
|
||||
.list()
|
||||
})
|
||||
.await?;
|
||||
let comments = match comments {
|
||||
Ok(comments) => comments,
|
||||
Err(_) => return Err(ApiError::err("couldnt_get_comments").into()),
|
||||
};
|
||||
|
||||
Ok(GetCommentsResponse { comments })
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a comment report and notifies the moderators of the community
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl Perform for CreateCommentReport {
|
||||
type Response = CreateCommentReportResponse;
|
||||
|
||||
async fn perform(
|
||||
&self,
|
||||
context: &Data<LemmyContext>,
|
||||
websocket_id: Option<ConnectionId>,
|
||||
) -> Result<CreateCommentReportResponse, LemmyError> {
|
||||
let data: &CreateCommentReport = &self;
|
||||
let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
|
||||
|
||||
// check size of report and check for whitespace
|
||||
let reason = data.reason.trim();
|
||||
if reason.is_empty() {
|
||||
return Err(ApiError::err("report_reason_required").into());
|
||||
}
|
||||
if reason.chars().count() > 1000 {
|
||||
return Err(ApiError::err("report_too_long").into());
|
||||
}
|
||||
|
||||
let person_id = local_user_view.person.id;
|
||||
let comment_id = data.comment_id;
|
||||
let comment_view = blocking(context.pool(), move |conn| {
|
||||
CommentView::read(&conn, comment_id, None)
|
||||
})
|
||||
.await??;
|
||||
|
||||
check_community_ban(person_id, comment_view.community.id, context.pool()).await?;
|
||||
|
||||
let report_form = CommentReportForm {
|
||||
creator_id: person_id,
|
||||
comment_id,
|
||||
original_comment_text: comment_view.comment.content,
|
||||
reason: data.reason.to_owned(),
|
||||
};
|
||||
|
||||
let report = match blocking(context.pool(), move |conn| {
|
||||
CommentReport::report(conn, &report_form)
|
||||
})
|
||||
.await?
|
||||
{
|
||||
Ok(report) => report,
|
||||
Err(_e) => return Err(ApiError::err("couldnt_create_report").into()),
|
||||
};
|
||||
|
||||
let res = CreateCommentReportResponse { success: true };
|
||||
|
||||
context.chat_server().do_send(SendUserRoomMessage {
|
||||
op: UserOperation::CreateCommentReport,
|
||||
response: res.clone(),
|
||||
local_recipient_id: local_user_view.local_user.id,
|
||||
websocket_id,
|
||||
});
|
||||
|
||||
context.chat_server().do_send(SendModRoomMessage {
|
||||
op: UserOperation::CreateCommentReport,
|
||||
response: report,
|
||||
community_id: comment_view.community.id,
|
||||
websocket_id,
|
||||
});
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves or unresolves a comment report and notifies the moderators of the community
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl Perform for ResolveCommentReport {
|
||||
type Response = ResolveCommentReportResponse;
|
||||
|
||||
async fn perform(
|
||||
&self,
|
||||
context: &Data<LemmyContext>,
|
||||
websocket_id: Option<ConnectionId>,
|
||||
) -> Result<ResolveCommentReportResponse, LemmyError> {
|
||||
let data: &ResolveCommentReport = &self;
|
||||
let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
|
||||
|
||||
let report_id = data.report_id;
|
||||
let report = blocking(context.pool(), move |conn| {
|
||||
CommentReportView::read(&conn, report_id)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let person_id = local_user_view.person.id;
|
||||
is_mod_or_admin(context.pool(), person_id, report.community.id).await?;
|
||||
|
||||
let resolved = data.resolved;
|
||||
let resolve_fun = move |conn: &'_ _| {
|
||||
if resolved {
|
||||
CommentReport::resolve(conn, report_id, person_id)
|
||||
} else {
|
||||
CommentReport::unresolve(conn, report_id, person_id)
|
||||
}
|
||||
};
|
||||
|
||||
if blocking(context.pool(), resolve_fun).await?.is_err() {
|
||||
return Err(ApiError::err("couldnt_resolve_report").into());
|
||||
};
|
||||
|
||||
let report_id = data.report_id;
|
||||
let res = ResolveCommentReportResponse {
|
||||
report_id,
|
||||
resolved,
|
||||
};
|
||||
|
||||
context.chat_server().do_send(SendModRoomMessage {
|
||||
op: UserOperation::ResolveCommentReport,
|
||||
response: res.clone(),
|
||||
community_id: report.community.id,
|
||||
websocket_id,
|
||||
});
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
|
||||
/// Lists comment reports for a community if an id is supplied
|
||||
/// or returns all comment reports for communities a user moderates
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl Perform for ListCommentReports {
|
||||
type Response = ListCommentReportsResponse;
|
||||
|
||||
async fn perform(
|
||||
&self,
|
||||
context: &Data<LemmyContext>,
|
||||
websocket_id: Option<ConnectionId>,
|
||||
) -> Result<ListCommentReportsResponse, LemmyError> {
|
||||
let data: &ListCommentReports = &self;
|
||||
let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
|
||||
|
||||
let person_id = local_user_view.person.id;
|
||||
let community_id = data.community;
|
||||
let community_ids =
|
||||
collect_moderated_communities(person_id, community_id, context.pool()).await?;
|
||||
|
||||
let page = data.page;
|
||||
let limit = data.limit;
|
||||
let comments = blocking(context.pool(), move |conn| {
|
||||
CommentReportQueryBuilder::create(conn)
|
||||
.community_ids(community_ids)
|
||||
.page(page)
|
||||
.limit(limit)
|
||||
.list()
|
||||
})
|
||||
.await??;
|
||||
|
||||
let res = ListCommentReportsResponse { comments };
|
||||
|
||||
context.chat_server().do_send(SendUserRoomMessage {
|
||||
op: UserOperation::ListCommentReports,
|
||||
response: res.clone(),
|
||||
local_recipient_id: local_user_view.local_user.id,
|
||||
websocket_id,
|
||||
});
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
}
|
903
crates/api/src/community.rs
Normal file
903
crates/api/src/community.rs
Normal file
|
@ -0,0 +1,903 @@
|
|||
use crate::{
|
||||
check_community_ban,
|
||||
get_local_user_view_from_jwt,
|
||||
get_local_user_view_from_jwt_opt,
|
||||
is_admin,
|
||||
is_mod_or_admin,
|
||||
Perform,
|
||||
};
|
||||
use actix_web::web::Data;
|
||||
use anyhow::Context;
|
||||
use lemmy_api_structs::{blocking, community::*};
|
||||
use lemmy_apub::{
|
||||
generate_apub_endpoint,
|
||||
generate_followers_url,
|
||||
generate_inbox_url,
|
||||
generate_shared_inbox_url,
|
||||
ActorType,
|
||||
EndpointType,
|
||||
};
|
||||
use lemmy_db_queries::{
|
||||
diesel_option_overwrite_to_url,
|
||||
source::{
|
||||
comment::Comment_,
|
||||
community::{CommunityModerator_, Community_},
|
||||
post::Post_,
|
||||
},
|
||||
ApubObject,
|
||||
Bannable,
|
||||
Crud,
|
||||
Followable,
|
||||
Joinable,
|
||||
ListingType,
|
||||
SortType,
|
||||
};
|
||||
use lemmy_db_schema::{
|
||||
naive_now,
|
||||
source::{comment::Comment, community::*, moderator::*, post::Post, site::*},
|
||||
PersonId,
|
||||
};
|
||||
use lemmy_db_views::comment_view::CommentQueryBuilder;
|
||||
use lemmy_db_views_actor::{
|
||||
community_follower_view::CommunityFollowerView,
|
||||
community_moderator_view::CommunityModeratorView,
|
||||
community_view::{CommunityQueryBuilder, CommunityView},
|
||||
person_view::PersonViewSafe,
|
||||
};
|
||||
use lemmy_utils::{
|
||||
apub::generate_actor_keypair,
|
||||
location_info,
|
||||
utils::{check_slurs, check_slurs_opt, is_valid_community_name, naive_from_unix},
|
||||
ApiError,
|
||||
ConnectionId,
|
||||
LemmyError,
|
||||
};
|
||||
use lemmy_websocket::{
|
||||
messages::{GetCommunityUsersOnline, SendCommunityRoomMessage},
|
||||
LemmyContext,
|
||||
UserOperation,
|
||||
};
|
||||
use std::str::FromStr;
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl Perform for GetCommunity {
|
||||
type Response = GetCommunityResponse;
|
||||
|
||||
async fn perform(
|
||||
&self,
|
||||
context: &Data<LemmyContext>,
|
||||
_websocket_id: Option<ConnectionId>,
|
||||
) -> Result<GetCommunityResponse, LemmyError> {
|
||||
let data: &GetCommunity = &self;
|
||||
let local_user_view = get_local_user_view_from_jwt_opt(&data.auth, context.pool()).await?;
|
||||
let person_id = local_user_view.map(|u| u.person.id);
|
||||
|
||||
let community_id = match data.id {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
let name = data.name.to_owned().unwrap_or_else(|| "main".to_string());
|
||||
match blocking(context.pool(), move |conn| {
|
||||
Community::read_from_name(conn, &name)
|
||||
})
|
||||
.await?
|
||||
{
|
||||
Ok(community) => community,
|
||||
Err(_e) => return Err(ApiError::err("couldnt_find_community").into()),
|
||||
}
|
||||
.id
|
||||
}
|
||||
};
|
||||
|
||||
let community_view = match blocking(context.pool(), move |conn| {
|
||||
CommunityView::read(conn, community_id, person_id)
|
||||
})
|
||||
.await?
|
||||
{
|
||||
Ok(community) => community,
|
||||
Err(_e) => return Err(ApiError::err("couldnt_find_community").into()),
|
||||
};
|
||||
|
||||
let moderators: Vec<CommunityModeratorView> = match blocking(context.pool(), move |conn| {
|
||||
CommunityModeratorView::for_community(conn, community_id)
|
||||
})
|
||||
.await?
|
||||
{
|
||||
Ok(moderators) => moderators,
|
||||
Err(_e) => return Err(ApiError::err("couldnt_find_community").into()),
|
||||
};
|
||||
|
||||
let online = context
|
||||
.chat_server()
|
||||
.send(GetCommunityUsersOnline { community_id })
|
||||
.await
|
||||
.unwrap_or(1);
|
||||
|
||||
let res = GetCommunityResponse {
|
||||
community_view,
|
||||
moderators,
|
||||
online,
|
||||
};
|
||||
|
||||
// Return the jwt
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl Perform for CreateCommunity {
|
||||
type Response = CommunityResponse;
|
||||
|
||||
async fn perform(
|
||||
&self,
|
||||
context: &Data<LemmyContext>,
|
||||
_websocket_id: Option<ConnectionId>,
|
||||
) -> Result<CommunityResponse, LemmyError> {
|
||||
let data: &CreateCommunity = &self;
|
||||
let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
|
||||
|
||||
check_slurs(&data.name)?;
|
||||
check_slurs(&data.title)?;
|
||||
check_slurs_opt(&data.description)?;
|
||||
|
||||
if !is_valid_community_name(&data.name) {
|
||||
return Err(ApiError::err("invalid_community_name").into());
|
||||
}
|
||||
|
||||
// Double check for duplicate community actor_ids
|
||||
let community_actor_id = generate_apub_endpoint(EndpointType::Community, &data.name)?;
|
||||
let actor_id_cloned = community_actor_id.to_owned();
|
||||
let community_dupe = blocking(context.pool(), move |conn| {
|
||||
Community::read_from_apub_id(conn, &actor_id_cloned)
|
||||
})
|
||||
.await?;
|
||||
if community_dupe.is_ok() {
|
||||
return Err(ApiError::err("community_already_exists").into());
|
||||
}
|
||||
|
||||
// Check to make sure the icon and banners are urls
|
||||
let icon = diesel_option_overwrite_to_url(&data.icon)?;
|
||||
let banner = diesel_option_overwrite_to_url(&data.banner)?;
|
||||
|
||||
// When you create a community, make sure the user becomes a moderator and a follower
|
||||
let keypair = generate_actor_keypair()?;
|
||||
|
||||
let community_form = CommunityForm {
|
||||
name: data.name.to_owned(),
|
||||
title: data.title.to_owned(),
|
||||
description: data.description.to_owned(),
|
||||
icon,
|
||||
banner,
|
||||
creator_id: local_user_view.person.id,
|
||||
removed: None,
|
||||
deleted: None,
|
||||
nsfw: data.nsfw,
|
||||
updated: None,
|
||||
actor_id: Some(community_actor_id.to_owned()),
|
||||
local: true,
|
||||
private_key: Some(keypair.private_key),
|
||||
public_key: Some(keypair.public_key),
|
||||
last_refreshed_at: None,
|
||||
published: None,
|
||||
followers_url: Some(generate_followers_url(&community_actor_id)?),
|
||||
inbox_url: Some(generate_inbox_url(&community_actor_id)?),
|
||||
shared_inbox_url: Some(Some(generate_shared_inbox_url(&community_actor_id)?)),
|
||||
};
|
||||
|
||||
let inserted_community = match blocking(context.pool(), move |conn| {
|
||||
Community::create(conn, &community_form)
|
||||
})
|
||||
.await?
|
||||
{
|
||||
Ok(community) => community,
|
||||
Err(_e) => return Err(ApiError::err("community_already_exists").into()),
|
||||
};
|
||||
|
||||
// The community creator becomes a moderator
|
||||
let community_moderator_form = CommunityModeratorForm {
|
||||
community_id: inserted_community.id,
|
||||
person_id: local_user_view.person.id,
|
||||
};
|
||||
|
||||
let join = move |conn: &'_ _| CommunityModerator::join(conn, &community_moderator_form);
|
||||
if blocking(context.pool(), join).await?.is_err() {
|
||||
return Err(ApiError::err("community_moderator_already_exists").into());
|
||||
}
|
||||
|
||||
// Follow your own community
|
||||
let community_follower_form = CommunityFollowerForm {
|
||||
community_id: inserted_community.id,
|
||||
person_id: local_user_view.person.id,
|
||||
pending: false,
|
||||
};
|
||||
|
||||
let follow = move |conn: &'_ _| CommunityFollower::follow(conn, &community_follower_form);
|
||||
if blocking(context.pool(), follow).await?.is_err() {
|
||||
return Err(ApiError::err("community_follower_already_exists").into());
|
||||
}
|
||||
|
||||
let person_id = local_user_view.person.id;
|
||||
let community_view = blocking(context.pool(), move |conn| {
|
||||
CommunityView::read(conn, inserted_community.id, Some(person_id))
|
||||
})
|
||||
.await??;
|
||||
|
||||
Ok(CommunityResponse { community_view })
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl Perform for EditCommunity {
|
||||
type Response = CommunityResponse;
|
||||
|
||||
async fn perform(
|
||||
&self,
|
||||
context: &Data<LemmyContext>,
|
||||
websocket_id: Option<ConnectionId>,
|
||||
) -> Result<CommunityResponse, LemmyError> {
|
||||
let data: &EditCommunity = &self;
|
||||
let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
|
||||
|
||||
check_slurs(&data.title)?;
|
||||
check_slurs_opt(&data.description)?;
|
||||
|
||||
// Verify its a mod (only mods can edit it)
|
||||
let community_id = data.community_id;
|
||||
let mods: Vec<PersonId> = blocking(context.pool(), move |conn| {
|
||||
CommunityModeratorView::for_community(conn, community_id)
|
||||
.map(|v| v.into_iter().map(|m| m.moderator.id).collect())
|
||||
})
|
||||
.await??;
|
||||
if !mods.contains(&local_user_view.person.id) {
|
||||
return Err(ApiError::err("not_a_moderator").into());
|
||||
}
|
||||
|
||||
let community_id = data.community_id;
|
||||
let read_community = blocking(context.pool(), move |conn| {
|
||||
Community::read(conn, community_id)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let icon = diesel_option_overwrite_to_url(&data.icon)?;
|
||||
let banner = diesel_option_overwrite_to_url(&data.banner)?;
|
||||
|
||||
let community_form = CommunityForm {
|
||||
name: read_community.name,
|
||||
title: data.title.to_owned(),
|
||||
description: data.description.to_owned(),
|
||||
icon,
|
||||
banner,
|
||||
creator_id: read_community.creator_id,
|
||||
removed: Some(read_community.removed),
|
||||
deleted: Some(read_community.deleted),
|
||||
nsfw: data.nsfw,
|
||||
updated: Some(naive_now()),
|
||||
actor_id: Some(read_community.actor_id),
|
||||
local: read_community.local,
|
||||
private_key: read_community.private_key,
|
||||
public_key: read_community.public_key,
|
||||
last_refreshed_at: None,
|
||||
published: None,
|
||||
followers_url: None,
|
||||
inbox_url: None,
|
||||
shared_inbox_url: None,
|
||||
};
|
||||
|
||||
let community_id = data.community_id;
|
||||
match blocking(context.pool(), move |conn| {
|
||||
Community::update(conn, community_id, &community_form)
|
||||
})
|
||||
.await?
|
||||
{
|
||||
Ok(community) => community,
|
||||
Err(_e) => return Err(ApiError::err("couldnt_update_community").into()),
|
||||
};
|
||||
|
||||
// TODO there needs to be some kind of an apub update
|
||||
// process for communities and users
|
||||
|
||||
let community_id = data.community_id;
|
||||
let person_id = local_user_view.person.id;
|
||||
let community_view = blocking(context.pool(), move |conn| {
|
||||
CommunityView::read(conn, community_id, Some(person_id))
|
||||
})
|
||||
.await??;
|
||||
|
||||
let res = CommunityResponse { community_view };
|
||||
|
||||
send_community_websocket(&res, context, websocket_id, UserOperation::EditCommunity);
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl Perform for DeleteCommunity {
|
||||
type Response = CommunityResponse;
|
||||
|
||||
async fn perform(
|
||||
&self,
|
||||
context: &Data<LemmyContext>,
|
||||
websocket_id: Option<ConnectionId>,
|
||||
) -> Result<CommunityResponse, LemmyError> {
|
||||
let data: &DeleteCommunity = &self;
|
||||
let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
|
||||
|
||||
// Verify its the creator (only a creator can delete the community)
|
||||
let community_id = data.community_id;
|
||||
let read_community = blocking(context.pool(), move |conn| {
|
||||
Community::read(conn, community_id)
|
||||
})
|
||||
.await??;
|
||||
if read_community.creator_id != local_user_view.person.id {
|
||||
return Err(ApiError::err("no_community_edit_allowed").into());
|
||||
}
|
||||
|
||||
// Do the delete
|
||||
let community_id = data.community_id;
|
||||
let deleted = data.deleted;
|
||||
let updated_community = match blocking(context.pool(), move |conn| {
|
||||
Community::update_deleted(conn, community_id, deleted)
|
||||
})
|
||||
.await?
|
||||
{
|
||||
Ok(community) => community,
|
||||
Err(_e) => return Err(ApiError::err("couldnt_update_community").into()),
|
||||
};
|
||||
|
||||
// Send apub messages
|
||||
if deleted {
|
||||
updated_community.send_delete(context).await?;
|
||||
} else {
|
||||
updated_community.send_undo_delete(context).await?;
|
||||
}
|
||||
|
||||
let community_id = data.community_id;
|
||||
let person_id = local_user_view.person.id;
|
||||
let community_view = blocking(context.pool(), move |conn| {
|
||||
CommunityView::read(conn, community_id, Some(person_id))
|
||||
})
|
||||
.await??;
|
||||
|
||||
let res = CommunityResponse { community_view };
|
||||
|
||||
send_community_websocket(&res, context, websocket_id, UserOperation::DeleteCommunity);
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl Perform for RemoveCommunity {
|
||||
type Response = CommunityResponse;
|
||||
|
||||
async fn perform(
|
||||
&self,
|
||||
context: &Data<LemmyContext>,
|
||||
websocket_id: Option<ConnectionId>,
|
||||
) -> Result<CommunityResponse, LemmyError> {
|
||||
let data: &RemoveCommunity = &self;
|
||||
let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
|
||||
|
||||
// Verify its an admin (only an admin can remove a community)
|
||||
is_admin(&local_user_view)?;
|
||||
|
||||
// Do the remove
|
||||
let community_id = data.community_id;
|
||||
let removed = data.removed;
|
||||
let updated_community = match blocking(context.pool(), move |conn| {
|
||||
Community::update_removed(conn, community_id, removed)
|
||||
})
|
||||
.await?
|
||||
{
|
||||
Ok(community) => community,
|
||||
Err(_e) => return Err(ApiError::err("couldnt_update_community").into()),
|
||||
};
|
||||
|
||||
// Mod tables
|
||||
let expires = match data.expires {
|
||||
Some(time) => Some(naive_from_unix(time)),
|
||||
None => None,
|
||||
};
|
||||
let form = ModRemoveCommunityForm {
|
||||
mod_person_id: local_user_view.person.id,
|
||||
community_id: data.community_id,
|
||||
removed: Some(removed),
|
||||
reason: data.reason.to_owned(),
|
||||
expires,
|
||||
};
|
||||
blocking(context.pool(), move |conn| {
|
||||
ModRemoveCommunity::create(conn, &form)
|
||||
})
|
||||
.await??;
|
||||
|
||||
// Apub messages
|
||||
if removed {
|
||||
updated_community.send_remove(context).await?;
|
||||
} else {
|
||||
updated_community.send_undo_remove(context).await?;
|
||||
}
|
||||
|
||||
let community_id = data.community_id;
|
||||
let person_id = local_user_view.person.id;
|
||||
let community_view = blocking(context.pool(), move |conn| {
|
||||
CommunityView::read(conn, community_id, Some(person_id))
|
||||
})
|
||||
.await??;
|
||||
|
||||
let res = CommunityResponse { community_view };
|
||||
|
||||
send_community_websocket(&res, context, websocket_id, UserOperation::RemoveCommunity);
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl Perform for ListCommunities {
|
||||
type Response = ListCommunitiesResponse;
|
||||
|
||||
async fn perform(
|
||||
&self,
|
||||
context: &Data<LemmyContext>,
|
||||
_websocket_id: Option<ConnectionId>,
|
||||
) -> Result<ListCommunitiesResponse, LemmyError> {
|
||||
let data: &ListCommunities = &self;
|
||||
let local_user_view = get_local_user_view_from_jwt_opt(&data.auth, context.pool()).await?;
|
||||
|
||||
let person_id = match &local_user_view {
|
||||
Some(uv) => Some(uv.person.id),
|
||||
None => None,
|
||||
};
|
||||
|
||||
// Don't show NSFW by default
|
||||
let show_nsfw = match &local_user_view {
|
||||
Some(uv) => uv.local_user.show_nsfw,
|
||||
None => false,
|
||||
};
|
||||
|
||||
let type_ = ListingType::from_str(&data.type_)?;
|
||||
let sort = SortType::from_str(&data.sort)?;
|
||||
|
||||
let page = data.page;
|
||||
let limit = data.limit;
|
||||
let communities = blocking(context.pool(), move |conn| {
|
||||
CommunityQueryBuilder::create(conn)
|
||||
.listing_type(&type_)
|
||||
.sort(&sort)
|
||||
.show_nsfw(show_nsfw)
|
||||
.my_person_id(person_id)
|
||||
.page(page)
|
||||
.limit(limit)
|
||||
.list()
|
||||
})
|
||||
.await??;
|
||||
|
||||
// Return the jwt
|
||||
Ok(ListCommunitiesResponse { communities })
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl Perform for FollowCommunity {
|
||||
type Response = CommunityResponse;
|
||||
|
||||
async fn perform(
|
||||
&self,
|
||||
context: &Data<LemmyContext>,
|
||||
_websocket_id: Option<ConnectionId>,
|
||||
) -> Result<CommunityResponse, LemmyError> {
|
||||
let data: &FollowCommunity = &self;
|
||||
let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
|
||||
|
||||
let community_id = data.community_id;
|
||||
let community = blocking(context.pool(), move |conn| {
|
||||
Community::read(conn, community_id)
|
||||
})
|
||||
.await??;
|
||||
let community_follower_form = CommunityFollowerForm {
|
||||
community_id: data.community_id,
|
||||
person_id: local_user_view.person.id,
|
||||
pending: false,
|
||||
};
|
||||
|
||||
if community.local {
|
||||
if data.follow {
|
||||
check_community_ban(local_user_view.person.id, community_id, context.pool()).await?;
|
||||
|
||||
let follow = move |conn: &'_ _| CommunityFollower::follow(conn, &community_follower_form);
|
||||
if blocking(context.pool(), follow).await?.is_err() {
|
||||
return Err(ApiError::err("community_follower_already_exists").into());
|
||||
}
|
||||
} else {
|
||||
let unfollow =
|
||||
move |conn: &'_ _| CommunityFollower::unfollow(conn, &community_follower_form);
|
||||
if blocking(context.pool(), unfollow).await?.is_err() {
|
||||
return Err(ApiError::err("community_follower_already_exists").into());
|
||||
}
|
||||
}
|
||||
} else if data.follow {
|
||||
// Dont actually add to the community followers here, because you need
|
||||
// to wait for the accept
|
||||
local_user_view
|
||||
.person
|
||||
.send_follow(&community.actor_id(), context)
|
||||
.await?;
|
||||
} else {
|
||||
local_user_view
|
||||
.person
|
||||
.send_unfollow(&community.actor_id(), context)
|
||||
.await?;
|
||||
let unfollow = move |conn: &'_ _| CommunityFollower::unfollow(conn, &community_follower_form);
|
||||
if blocking(context.pool(), unfollow).await?.is_err() {
|
||||
return Err(ApiError::err("community_follower_already_exists").into());
|
||||
}
|
||||
}
|
||||
|
||||
let community_id = data.community_id;
|
||||
let person_id = local_user_view.person.id;
|
||||
let mut community_view = blocking(context.pool(), move |conn| {
|
||||
CommunityView::read(conn, community_id, Some(person_id))
|
||||
})
|
||||
.await??;
|
||||
|
||||
// TODO: this needs to return a "pending" state, until Accept is received from the remote server
|
||||
// For now, just assume that remote follows are accepted.
|
||||
// Otherwise, the subscribed will be null
|
||||
if !community.local {
|
||||
community_view.subscribed = data.follow;
|
||||
}
|
||||
|
||||
Ok(CommunityResponse { community_view })
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl Perform for GetFollowedCommunities {
|
||||
type Response = GetFollowedCommunitiesResponse;
|
||||
|
||||
async fn perform(
|
||||
&self,
|
||||
context: &Data<LemmyContext>,
|
||||
_websocket_id: Option<ConnectionId>,
|
||||
) -> Result<GetFollowedCommunitiesResponse, LemmyError> {
|
||||
let data: &GetFollowedCommunities = &self;
|
||||
let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
|
||||
|
||||
let person_id = local_user_view.person.id;
|
||||
let communities = match blocking(context.pool(), move |conn| {
|
||||
CommunityFollowerView::for_person(conn, person_id)
|
||||
})
|
||||
.await?
|
||||
{
|
||||
Ok(communities) => communities,
|
||||
_ => return Err(ApiError::err("system_err_login").into()),
|
||||
};
|
||||
|
||||
// Return the jwt
|
||||
Ok(GetFollowedCommunitiesResponse { communities })
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl Perform for BanFromCommunity {
|
||||
type Response = BanFromCommunityResponse;
|
||||
|
||||
async fn perform(
|
||||
&self,
|
||||
context: &Data<LemmyContext>,
|
||||
websocket_id: Option<ConnectionId>,
|
||||
) -> Result<BanFromCommunityResponse, LemmyError> {
|
||||
let data: &BanFromCommunity = &self;
|
||||
let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
|
||||
|
||||
let community_id = data.community_id;
|
||||
let banned_person_id = data.person_id;
|
||||
|
||||
// Verify that only mods or admins can ban
|
||||
is_mod_or_admin(context.pool(), local_user_view.person.id, community_id).await?;
|
||||
|
||||
let community_user_ban_form = CommunityPersonBanForm {
|
||||
community_id: data.community_id,
|
||||
person_id: data.person_id,
|
||||
};
|
||||
|
||||
if data.ban {
|
||||
let ban = move |conn: &'_ _| CommunityPersonBan::ban(conn, &community_user_ban_form);
|
||||
if blocking(context.pool(), ban).await?.is_err() {
|
||||
return Err(ApiError::err("community_user_already_banned").into());
|
||||
}
|
||||
|
||||
// Also unsubscribe them from the community, if they are subscribed
|
||||
let community_follower_form = CommunityFollowerForm {
|
||||
community_id: data.community_id,
|
||||
person_id: banned_person_id,
|
||||
pending: false,
|
||||
};
|
||||
blocking(context.pool(), move |conn: &'_ _| {
|
||||
CommunityFollower::unfollow(conn, &community_follower_form)
|
||||
})
|
||||
.await?
|
||||
.ok();
|
||||
} else {
|
||||
let unban = move |conn: &'_ _| CommunityPersonBan::unban(conn, &community_user_ban_form);
|
||||
if blocking(context.pool(), unban).await?.is_err() {
|
||||
return Err(ApiError::err("community_user_already_banned").into());
|
||||
}
|
||||
}
|
||||
|
||||
// Remove/Restore their data if that's desired
|
||||
if data.remove_data {
|
||||
// Posts
|
||||
blocking(context.pool(), move |conn: &'_ _| {
|
||||
Post::update_removed_for_creator(conn, banned_person_id, Some(community_id), true)
|
||||
})
|
||||
.await??;
|
||||
|
||||
// Comments
|
||||
// TODO Diesel doesn't allow updates with joins, so this has to be a loop
|
||||
let comments = blocking(context.pool(), move |conn| {
|
||||
CommentQueryBuilder::create(conn)
|
||||
.creator_id(banned_person_id)
|
||||
.community_id(community_id)
|
||||
.limit(std::i64::MAX)
|
||||
.list()
|
||||
})
|
||||
.await??;
|
||||
|
||||
for comment_view in &comments {
|
||||
let comment_id = comment_view.comment.id;
|
||||
blocking(context.pool(), move |conn: &'_ _| {
|
||||
Comment::update_removed(conn, comment_id, true)
|
||||
})
|
||||
.await??;
|
||||
}
|
||||
}
|
||||
|
||||
// Mod tables
|
||||
// TODO eventually do correct expires
|
||||
let expires = match data.expires {
|
||||
Some(time) => Some(naive_from_unix(time)),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let form = ModBanFromCommunityForm {
|
||||
mod_person_id: local_user_view.person.id,
|
||||
other_person_id: data.person_id,
|
||||
community_id: data.community_id,
|
||||
reason: data.reason.to_owned(),
|
||||
banned: Some(data.ban),
|
||||
expires,
|
||||
};
|
||||
blocking(context.pool(), move |conn| {
|
||||
ModBanFromCommunity::create(conn, &form)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let person_id = data.person_id;
|
||||
let person_view = blocking(context.pool(), move |conn| {
|
||||
PersonViewSafe::read(conn, person_id)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let res = BanFromCommunityResponse {
|
||||
person_view,
|
||||
banned: data.ban,
|
||||
};
|
||||
|
||||
context.chat_server().do_send(SendCommunityRoomMessage {
|
||||
op: UserOperation::BanFromCommunity,
|
||||
response: res.clone(),
|
||||
community_id,
|
||||
websocket_id,
|
||||
});
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl Perform for AddModToCommunity {
|
||||
type Response = AddModToCommunityResponse;
|
||||
|
||||
async fn perform(
|
||||
&self,
|
||||
context: &Data<LemmyContext>,
|
||||
websocket_id: Option<ConnectionId>,
|
||||
) -> Result<AddModToCommunityResponse, LemmyError> {
|
||||
let data: &AddModToCommunity = &self;
|
||||
let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
|
||||
|
||||
let community_moderator_form = CommunityModeratorForm {
|
||||
community_id: data.community_id,
|
||||
person_id: data.person_id,
|
||||
};
|
||||
|
||||
let community_id = data.community_id;
|
||||
|
||||
// Verify that only mods or admins can add mod
|
||||
is_mod_or_admin(context.pool(), local_user_view.person.id, community_id).await?;
|
||||
|
||||
if data.added {
|
||||
let join = move |conn: &'_ _| CommunityModerator::join(conn, &community_moderator_form);
|
||||
if blocking(context.pool(), join).await?.is_err() {
|
||||
return Err(ApiError::err("community_moderator_already_exists").into());
|
||||
}
|
||||
} else {
|
||||
let leave = move |conn: &'_ _| CommunityModerator::leave(conn, &community_moderator_form);
|
||||
if blocking(context.pool(), leave).await?.is_err() {
|
||||
return Err(ApiError::err("community_moderator_already_exists").into());
|
||||
}
|
||||
}
|
||||
|
||||
// Mod tables
|
||||
let form = ModAddCommunityForm {
|
||||
mod_person_id: local_user_view.person.id,
|
||||
other_person_id: data.person_id,
|
||||
community_id: data.community_id,
|
||||
removed: Some(!data.added),
|
||||
};
|
||||
blocking(context.pool(), move |conn| {
|
||||
ModAddCommunity::create(conn, &form)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let community_id = data.community_id;
|
||||
let moderators = blocking(context.pool(), move |conn| {
|
||||
CommunityModeratorView::for_community(conn, community_id)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let res = AddModToCommunityResponse { moderators };
|
||||
|
||||
context.chat_server().do_send(SendCommunityRoomMessage {
|
||||
op: UserOperation::AddModToCommunity,
|
||||
response: res.clone(),
|
||||
community_id,
|
||||
websocket_id,
|
||||
});
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl Perform for TransferCommunity {
|
||||
type Response = GetCommunityResponse;
|
||||
|
||||
async fn perform(
|
||||
&self,
|
||||
context: &Data<LemmyContext>,
|
||||
_websocket_id: Option<ConnectionId>,
|
||||
) -> Result<GetCommunityResponse, LemmyError> {
|
||||
let data: &TransferCommunity = &self;
|
||||
let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
|
||||
|
||||
let community_id = data.community_id;
|
||||
let read_community = blocking(context.pool(), move |conn| {
|
||||
Community::read(conn, community_id)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let site_creator_id = blocking(context.pool(), move |conn| {
|
||||
Site::read(conn, 1).map(|s| s.creator_id)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let mut admins = blocking(context.pool(), move |conn| PersonViewSafe::admins(conn)).await??;
|
||||
|
||||
// Making sure the creator, if an admin, is at the top
|
||||
let creator_index = admins
|
||||
.iter()
|
||||
.position(|r| r.person.id == site_creator_id)
|
||||
.context(location_info!())?;
|
||||
let creator_person = admins.remove(creator_index);
|
||||
admins.insert(0, creator_person);
|
||||
|
||||
// Make sure user is the creator, or an admin
|
||||
if local_user_view.person.id != read_community.creator_id
|
||||
&& !admins
|
||||
.iter()
|
||||
.map(|a| a.person.id)
|
||||
.any(|x| x == local_user_view.person.id)
|
||||
{
|
||||
return Err(ApiError::err("not_an_admin").into());
|
||||
}
|
||||
|
||||
let community_id = data.community_id;
|
||||
let new_creator = data.person_id;
|
||||
let update = move |conn: &'_ _| Community::update_creator(conn, community_id, new_creator);
|
||||
if blocking(context.pool(), update).await?.is_err() {
|
||||
return Err(ApiError::err("couldnt_update_community").into());
|
||||
};
|
||||
|
||||
// You also have to re-do the community_moderator table, reordering it.
|
||||
let community_id = data.community_id;
|
||||
let mut community_mods = blocking(context.pool(), move |conn| {
|
||||
CommunityModeratorView::for_community(conn, community_id)
|
||||
})
|
||||
.await??;
|
||||
let creator_index = community_mods
|
||||
.iter()
|
||||
.position(|r| r.moderator.id == data.person_id)
|
||||
.context(location_info!())?;
|
||||
let creator_person = community_mods.remove(creator_index);
|
||||
community_mods.insert(0, creator_person);
|
||||
|
||||
let community_id = data.community_id;
|
||||
blocking(context.pool(), move |conn| {
|
||||
CommunityModerator::delete_for_community(conn, community_id)
|
||||
})
|
||||
.await??;
|
||||
|
||||
// TODO: this should probably be a bulk operation
|
||||
for cmod in &community_mods {
|
||||
let community_moderator_form = CommunityModeratorForm {
|
||||
community_id: cmod.community.id,
|
||||
person_id: cmod.moderator.id,
|
||||
};
|
||||
|
||||
let join = move |conn: &'_ _| CommunityModerator::join(conn, &community_moderator_form);
|
||||
if blocking(context.pool(), join).await?.is_err() {
|
||||
return Err(ApiError::err("community_moderator_already_exists").into());
|
||||
}
|
||||
}
|
||||
|
||||
// Mod tables
|
||||
let form = ModAddCommunityForm {
|
||||
mod_person_id: local_user_view.person.id,
|
||||
other_person_id: data.person_id,
|
||||
community_id: data.community_id,
|
||||
removed: Some(false),
|
||||
};
|
||||
blocking(context.pool(), move |conn| {
|
||||
ModAddCommunity::create(conn, &form)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let community_id = data.community_id;
|
||||
let person_id = local_user_view.person.id;
|
||||
let community_view = match blocking(context.pool(), move |conn| {
|
||||
CommunityView::read(conn, community_id, Some(person_id))
|
||||
})
|
||||
.await?
|
||||
{
|
||||
Ok(community) => community,
|
||||
Err(_e) => return Err(ApiError::err("couldnt_find_community").into()),
|
||||
};
|
||||
|
||||
let community_id = data.community_id;
|
||||
let moderators = match blocking(context.pool(), move |conn| {
|
||||
CommunityModeratorView::for_community(conn, community_id)
|
||||
})
|
||||
.await?
|
||||
{
|
||||
Ok(moderators) => moderators,
|
||||
Err(_e) => return Err(ApiError::err("couldnt_find_community").into()),
|
||||
};
|
||||
|
||||
// Return the jwt
|
||||
Ok(GetCommunityResponse {
|
||||
community_view,
|
||||
moderators,
|
||||
online: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn send_community_websocket(
|
||||
res: &CommunityResponse,
|
||||
context: &Data<LemmyContext>,
|
||||
websocket_id: Option<ConnectionId>,
|
||||
op: UserOperation,
|
||||
) {
|
||||
// Strip out the person id and subscribed when sending to others
|
||||
let mut res_sent = res.clone();
|
||||
res_sent.community_view.subscribed = false;
|
||||
|
||||
context.chat_server().do_send(SendCommunityRoomMessage {
|
||||
op,
|
||||
response: res_sent,
|
||||
community_id: res.community_view.community.id,
|
||||
websocket_id,
|
||||
});
|
||||
}
|
586
crates/api/src/lib.rs
Normal file
586
crates/api/src/lib.rs
Normal file
|
@ -0,0 +1,586 @@
|
|||
use actix_web::{web, web::Data};
|
||||
use lemmy_api_structs::{
|
||||
blocking,
|
||||
comment::*,
|
||||
community::*,
|
||||
person::*,
|
||||
post::*,
|
||||
site::*,
|
||||
websocket::*,
|
||||
};
|
||||
use lemmy_db_queries::{
|
||||
source::{
|
||||
community::{CommunityModerator_, Community_},
|
||||
site::Site_,
|
||||
},
|
||||
Crud,
|
||||
DbPool,
|
||||
};
|
||||
use lemmy_db_schema::{
|
||||
source::{
|
||||
community::{Community, CommunityModerator},
|
||||
post::Post,
|
||||
site::Site,
|
||||
},
|
||||
CommunityId,
|
||||
LocalUserId,
|
||||
PersonId,
|
||||
PostId,
|
||||
};
|
||||
use lemmy_db_views::local_user_view::{LocalUserSettingsView, LocalUserView};
|
||||
use lemmy_db_views_actor::{
|
||||
community_person_ban_view::CommunityPersonBanView,
|
||||
community_view::CommunityView,
|
||||
};
|
||||
use lemmy_utils::{
|
||||
claims::Claims,
|
||||
settings::structs::Settings,
|
||||
ApiError,
|
||||
ConnectionId,
|
||||
LemmyError,
|
||||
};
|
||||
use lemmy_websocket::{serialize_websocket_message, LemmyContext, UserOperation};
|
||||
use serde::Deserialize;
|
||||
use std::{env, process::Command};
|
||||
use url::Url;
|
||||
|
||||
pub mod comment;
|
||||
pub mod community;
|
||||
pub mod local_user;
|
||||
pub mod post;
|
||||
pub mod routes;
|
||||
pub mod site;
|
||||
pub mod websocket;
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
pub trait Perform {
|
||||
type Response: serde::ser::Serialize + Send;
|
||||
|
||||
async fn perform(
|
||||
&self,
|
||||
context: &Data<LemmyContext>,
|
||||
websocket_id: Option<ConnectionId>,
|
||||
) -> Result<Self::Response, LemmyError>;
|
||||
}
|
||||
|
||||
pub(crate) async fn is_mod_or_admin(
|
||||
pool: &DbPool,
|
||||
person_id: PersonId,
|
||||
community_id: CommunityId,
|
||||
) -> Result<(), LemmyError> {
|
||||
let is_mod_or_admin = blocking(pool, move |conn| {
|
||||
CommunityView::is_mod_or_admin(conn, person_id, community_id)
|
||||
})
|
||||
.await?;
|
||||
if !is_mod_or_admin {
|
||||
return Err(ApiError::err("not_a_mod_or_admin").into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn is_admin(local_user_view: &LocalUserView) -> Result<(), LemmyError> {
|
||||
if !local_user_view.local_user.admin {
|
||||
return Err(ApiError::err("not_an_admin").into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn get_post(post_id: PostId, pool: &DbPool) -> Result<Post, LemmyError> {
|
||||
match blocking(pool, move |conn| Post::read(conn, post_id)).await? {
|
||||
Ok(post) => Ok(post),
|
||||
Err(_e) => Err(ApiError::err("couldnt_find_post").into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn get_local_user_view_from_jwt(
|
||||
jwt: &str,
|
||||
pool: &DbPool,
|
||||
) -> Result<LocalUserView, LemmyError> {
|
||||
let claims = match Claims::decode(&jwt) {
|
||||
Ok(claims) => claims.claims,
|
||||
Err(_e) => return Err(ApiError::err("not_logged_in").into()),
|
||||
};
|
||||
let local_user_id = LocalUserId(claims.sub);
|
||||
let local_user_view =
|
||||
blocking(pool, move |conn| LocalUserView::read(conn, local_user_id)).await??;
|
||||
// Check for a site ban
|
||||
if local_user_view.person.banned {
|
||||
return Err(ApiError::err("site_ban").into());
|
||||
}
|
||||
|
||||
check_validator_time(&local_user_view.local_user.validator_time, &claims)?;
|
||||
|
||||
Ok(local_user_view)
|
||||
}
|
||||
|
||||
/// Checks if user's token was issued before user's password reset.
|
||||
pub(crate) fn check_validator_time(
|
||||
validator_time: &chrono::NaiveDateTime,
|
||||
claims: &Claims,
|
||||
) -> Result<(), LemmyError> {
|
||||
let user_validation_time = validator_time.timestamp();
|
||||
if user_validation_time > claims.iat {
|
||||
Err(ApiError::err("not_logged_in").into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn get_local_user_view_from_jwt_opt(
|
||||
jwt: &Option<String>,
|
||||
pool: &DbPool,
|
||||
) -> Result<Option<LocalUserView>, LemmyError> {
|
||||
match jwt {
|
||||
Some(jwt) => Ok(Some(get_local_user_view_from_jwt(jwt, pool).await?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn get_local_user_settings_view_from_jwt(
|
||||
jwt: &str,
|
||||
pool: &DbPool,
|
||||
) -> Result<LocalUserSettingsView, LemmyError> {
|
||||
let claims = match Claims::decode(&jwt) {
|
||||
Ok(claims) => claims.claims,
|
||||
Err(_e) => return Err(ApiError::err("not_logged_in").into()),
|
||||
};
|
||||
let local_user_id = LocalUserId(claims.sub);
|
||||
let local_user_view = blocking(pool, move |conn| {
|
||||
LocalUserSettingsView::read(conn, local_user_id)
|
||||
})
|
||||
.await??;
|
||||
// Check for a site ban
|
||||
if local_user_view.person.banned {
|
||||
return Err(ApiError::err("site_ban").into());
|
||||
}
|
||||
|
||||
check_validator_time(&local_user_view.local_user.validator_time, &claims)?;
|
||||
|
||||
Ok(local_user_view)
|
||||
}
|
||||
|
||||
pub(crate) async fn get_local_user_settings_view_from_jwt_opt(
|
||||
jwt: &Option<String>,
|
||||
pool: &DbPool,
|
||||
) -> Result<Option<LocalUserSettingsView>, LemmyError> {
|
||||
match jwt {
|
||||
Some(jwt) => Ok(Some(
|
||||
get_local_user_settings_view_from_jwt(jwt, pool).await?,
|
||||
)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn check_community_ban(
|
||||
person_id: PersonId,
|
||||
community_id: CommunityId,
|
||||
pool: &DbPool,
|
||||
) -> Result<(), LemmyError> {
|
||||
let is_banned =
|
||||
move |conn: &'_ _| CommunityPersonBanView::get(conn, person_id, community_id).is_ok();
|
||||
if blocking(pool, is_banned).await? {
|
||||
Err(ApiError::err("community_ban").into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn check_downvotes_enabled(score: i16, pool: &DbPool) -> Result<(), LemmyError> {
|
||||
if score == -1 {
|
||||
let site = blocking(pool, move |conn| Site::read_simple(conn)).await??;
|
||||
if !site.enable_downvotes {
|
||||
return Err(ApiError::err("downvotes_disabled").into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns a list of communities that the user moderates
|
||||
/// or if a community_id is supplied validates the user is a moderator
|
||||
/// of that community and returns the community id in a vec
|
||||
///
|
||||
/// * `person_id` - the person id of the moderator
|
||||
/// * `community_id` - optional community id to check for moderator privileges
|
||||
/// * `pool` - the diesel db pool
|
||||
pub(crate) async fn collect_moderated_communities(
|
||||
person_id: PersonId,
|
||||
community_id: Option<CommunityId>,
|
||||
pool: &DbPool,
|
||||
) -> Result<Vec<CommunityId>, LemmyError> {
|
||||
if let Some(community_id) = community_id {
|
||||
// if the user provides a community_id, just check for mod/admin privileges
|
||||
is_mod_or_admin(pool, person_id, community_id).await?;
|
||||
Ok(vec![community_id])
|
||||
} else {
|
||||
let ids = blocking(pool, move |conn: &'_ _| {
|
||||
CommunityModerator::get_person_moderated_communities(conn, person_id)
|
||||
})
|
||||
.await??;
|
||||
Ok(ids)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn build_federated_instances(
|
||||
pool: &DbPool,
|
||||
) -> Result<Option<FederatedInstances>, LemmyError> {
|
||||
if Settings::get().federation().enabled {
|
||||
let distinct_communities = blocking(pool, move |conn| {
|
||||
Community::distinct_federated_communities(conn)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let allowed = Settings::get().get_allowed_instances();
|
||||
let blocked = Settings::get().get_blocked_instances();
|
||||
|
||||
let mut linked = distinct_communities
|
||||
.iter()
|
||||
.map(|actor_id| Ok(Url::parse(actor_id)?.host_str().unwrap_or("").to_string()))
|
||||
.collect::<Result<Vec<String>, LemmyError>>()?;
|
||||
|
||||
if let Some(allowed) = allowed.as_ref() {
|
||||
linked.extend_from_slice(allowed);
|
||||
}
|
||||
|
||||
if let Some(blocked) = blocked.as_ref() {
|
||||
linked.retain(|a| !blocked.contains(a) && !a.eq(&Settings::get().hostname()));
|
||||
}
|
||||
|
||||
// Sort and remove dupes
|
||||
linked.sort_unstable();
|
||||
linked.dedup();
|
||||
|
||||
Ok(Some(FederatedInstances {
|
||||
linked,
|
||||
allowed,
|
||||
blocked,
|
||||
}))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn match_websocket_operation(
|
||||
context: LemmyContext,
|
||||
id: ConnectionId,
|
||||
op: UserOperation,
|
||||
data: &str,
|
||||
) -> Result<String, LemmyError> {
|
||||
match op {
|
||||
// User ops
|
||||
UserOperation::Login => do_websocket_operation::<Login>(context, id, op, data).await,
|
||||
UserOperation::Register => do_websocket_operation::<Register>(context, id, op, data).await,
|
||||
UserOperation::GetCaptcha => do_websocket_operation::<GetCaptcha>(context, id, op, data).await,
|
||||
UserOperation::GetPersonDetails => {
|
||||
do_websocket_operation::<GetPersonDetails>(context, id, op, data).await
|
||||
}
|
||||
UserOperation::GetReplies => do_websocket_operation::<GetReplies>(context, id, op, data).await,
|
||||
UserOperation::AddAdmin => do_websocket_operation::<AddAdmin>(context, id, op, data).await,
|
||||
UserOperation::BanPerson => do_websocket_operation::<BanPerson>(context, id, op, data).await,
|
||||
UserOperation::GetPersonMentions => {
|
||||
do_websocket_operation::<GetPersonMentions>(context, id, op, data).await
|
||||
}
|
||||
UserOperation::MarkPersonMentionAsRead => {
|
||||
do_websocket_operation::<MarkPersonMentionAsRead>(context, id, op, data).await
|
||||
}
|
||||
UserOperation::MarkAllAsRead => {
|
||||
do_websocket_operation::<MarkAllAsRead>(context, id, op, data).await
|
||||
}
|
||||
UserOperation::DeleteAccount => {
|
||||
do_websocket_operation::<DeleteAccount>(context, id, op, data).await
|
||||
}
|
||||
UserOperation::PasswordReset => {
|
||||
do_websocket_operation::<PasswordReset>(context, id, op, data).await
|
||||
}
|
||||
UserOperation::PasswordChange => {
|
||||
do_websocket_operation::<PasswordChange>(context, id, op, data).await
|
||||
}
|
||||
UserOperation::UserJoin => do_websocket_operation::<UserJoin>(context, id, op, data).await,
|
||||
UserOperation::PostJoin => do_websocket_operation::<PostJoin>(context, id, op, data).await,
|
||||
UserOperation::CommunityJoin => {
|
||||
do_websocket_operation::<CommunityJoin>(context, id, op, data).await
|
||||
}
|
||||
UserOperation::ModJoin => do_websocket_operation::<ModJoin>(context, id, op, data).await,
|
||||
UserOperation::SaveUserSettings => {
|
||||
do_websocket_operation::<SaveUserSettings>(context, id, op, data).await
|
||||
}
|
||||
UserOperation::GetReportCount => {
|
||||
do_websocket_operation::<GetReportCount>(context, id, op, data).await
|
||||
}
|
||||
|
||||
// Private Message ops
|
||||
UserOperation::CreatePrivateMessage => {
|
||||
do_websocket_operation::<CreatePrivateMessage>(context, id, op, data).await
|
||||
}
|
||||
UserOperation::EditPrivateMessage => {
|
||||
do_websocket_operation::<EditPrivateMessage>(context, id, op, data).await
|
||||
}
|
||||
UserOperation::DeletePrivateMessage => {
|
||||
do_websocket_operation::<DeletePrivateMessage>(context, id, op, data).await
|
||||
}
|
||||
UserOperation::MarkPrivateMessageAsRead => {
|
||||
do_websocket_operation::<MarkPrivateMessageAsRead>(context, id, op, data).await
|
||||
}
|
||||
UserOperation::GetPrivateMessages => {
|
||||
do_websocket_operation::<GetPrivateMessages>(context, id, op, data).await
|
||||
}
|
||||
|
||||
// Site ops
|
||||
UserOperation::GetModlog => do_websocket_operation::<GetModlog>(context, id, op, data).await,
|
||||
UserOperation::CreateSite => do_websocket_operation::<CreateSite>(context, id, op, data).await,
|
||||
UserOperation::EditSite => do_websocket_operation::<EditSite>(context, id, op, data).await,
|
||||
UserOperation::GetSite => do_websocket_operation::<GetSite>(context, id, op, data).await,
|
||||
UserOperation::GetSiteConfig => {
|
||||
do_websocket_operation::<GetSiteConfig>(context, id, op, data).await
|
||||
}
|
||||
UserOperation::SaveSiteConfig => {
|
||||
do_websocket_operation::<SaveSiteConfig>(context, id, op, data).await
|
||||
}
|
||||
UserOperation::Search => do_websocket_operation::<Search>(context, id, op, data).await,
|
||||
UserOperation::TransferCommunity => {
|
||||
do_websocket_operation::<TransferCommunity>(context, id, op, data).await
|
||||
}
|
||||
UserOperation::TransferSite => {
|
||||
do_websocket_operation::<TransferSite>(context, id, op, data).await
|
||||
}
|
||||
|
||||
// Community ops
|
||||
UserOperation::GetCommunity => {
|
||||
do_websocket_operation::<GetCommunity>(context, id, op, data).await
|
||||
}
|
||||
UserOperation::ListCommunities => {
|
||||
do_websocket_operation::<ListCommunities>(context, id, op, data).await
|
||||
}
|
||||
UserOperation::CreateCommunity => {
|
||||
do_websocket_operation::<CreateCommunity>(context, id, op, data).await
|
||||
}
|
||||
UserOperation::EditCommunity => {
|
||||
do_websocket_operation::<EditCommunity>(context, id, op, data).await
|
||||
}
|
||||
UserOperation::DeleteCommunity => {
|
||||
do_websocket_operation::<DeleteCommunity>(context, id, op, data).await
|
||||
}
|
||||
UserOperation::RemoveCommunity => {
|
||||
do_websocket_operation::<RemoveCommunity>(context, id, op, data).await
|
||||
}
|
||||
UserOperation::FollowCommunity => {
|
||||
do_websocket_operation::<FollowCommunity>(context, id, op, data).await
|
||||
}
|
||||
UserOperation::GetFollowedCommunities => {
|
||||
do_websocket_operation::<GetFollowedCommunities>(context, id, op, data).await
|
||||
}
|
||||
UserOperation::BanFromCommunity => {
|
||||
do_websocket_operation::<BanFromCommunity>(context, id, op, data).await
|
||||
}
|
||||
UserOperation::AddModToCommunity => {
|
||||
do_websocket_operation::<AddModToCommunity>(context, id, op, data).await
|
||||
}
|
||||
|
||||
// Post ops
|
||||
UserOperation::CreatePost => do_websocket_operation::<CreatePost>(context, id, op, data).await,
|
||||
UserOperation::GetPost => do_websocket_operation::<GetPost>(context, id, op, data).await,
|
||||
UserOperation::GetPosts => do_websocket_operation::<GetPosts>(context, id, op, data).await,
|
||||
UserOperation::EditPost => do_websocket_operation::<EditPost>(context, id, op, data).await,
|
||||
UserOperation::DeletePost => do_websocket_operation::<DeletePost>(context, id, op, data).await,
|
||||
UserOperation::RemovePost => do_websocket_operation::<RemovePost>(context, id, op, data).await,
|
||||
UserOperation::LockPost => do_websocket_operation::<LockPost>(context, id, op, data).await,
|
||||
UserOperation::StickyPost => do_websocket_operation::<StickyPost>(context, id, op, data).await,
|
||||
UserOperation::CreatePostLike => {
|
||||
do_websocket_operation::<CreatePostLike>(context, id, op, data).await
|
||||
}
|
||||
UserOperation::SavePost => do_websocket_operation::<SavePost>(context, id, op, data).await,
|
||||
UserOperation::CreatePostReport => {
|
||||
do_websocket_operation::<CreatePostReport>(context, id, op, data).await
|
||||
}
|
||||
UserOperation::ListPostReports => {
|
||||
do_websocket_operation::<ListPostReports>(context, id, op, data).await
|
||||
}
|
||||
UserOperation::ResolvePostReport => {
|
||||
do_websocket_operation::<ResolvePostReport>(context, id, op, data).await
|
||||
}
|
||||
|
||||
// Comment ops
|
||||
UserOperation::CreateComment => {
|
||||
do_websocket_operation::<CreateComment>(context, id, op, data).await
|
||||
}
|
||||
UserOperation::EditComment => {
|
||||
do_websocket_operation::<EditComment>(context, id, op, data).await
|
||||
}
|
||||
UserOperation::DeleteComment => {
|
||||
do_websocket_operation::<DeleteComment>(context, id, op, data).await
|
||||
}
|
||||
UserOperation::RemoveComment => {
|
||||
do_websocket_operation::<RemoveComment>(context, id, op, data).await
|
||||
}
|
||||
UserOperation::MarkCommentAsRead => {
|
||||
do_websocket_operation::<MarkCommentAsRead>(context, id, op, data).await
|
||||
}
|
||||
UserOperation::SaveComment => {
|
||||
do_websocket_operation::<SaveComment>(context, id, op, data).await
|
||||
}
|
||||
UserOperation::GetComments => {
|
||||
do_websocket_operation::<GetComments>(context, id, op, data).await
|
||||
}
|
||||
UserOperation::CreateCommentLike => {
|
||||
do_websocket_operation::<CreateCommentLike>(context, id, op, data).await
|
||||
}
|
||||
UserOperation::CreateCommentReport => {
|
||||
do_websocket_operation::<CreateCommentReport>(context, id, op, data).await
|
||||
}
|
||||
UserOperation::ListCommentReports => {
|
||||
do_websocket_operation::<ListCommentReports>(context, id, op, data).await
|
||||
}
|
||||
UserOperation::ResolveCommentReport => {
|
||||
do_websocket_operation::<ResolveCommentReport>(context, id, op, data).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn do_websocket_operation<'a, 'b, Data>(
|
||||
context: LemmyContext,
|
||||
id: ConnectionId,
|
||||
op: UserOperation,
|
||||
data: &str,
|
||||
) -> Result<String, LemmyError>
|
||||
where
|
||||
for<'de> Data: Deserialize<'de> + 'a,
|
||||
Data: Perform,
|
||||
{
|
||||
let parsed_data: Data = serde_json::from_str(&data)?;
|
||||
let res = parsed_data
|
||||
.perform(&web::Data::new(context), Some(id))
|
||||
.await?;
|
||||
serialize_websocket_message(&op, &res)
|
||||
}
|
||||
|
||||
pub(crate) fn captcha_espeak_wav_base64(captcha: &str) -> Result<String, LemmyError> {
|
||||
let mut built_text = String::new();
|
||||
|
||||
// Building proper speech text for espeak
|
||||
for mut c in captcha.chars() {
|
||||
let new_str = if c.is_alphabetic() {
|
||||
if c.is_lowercase() {
|
||||
c.make_ascii_uppercase();
|
||||
format!("lower case {} ... ", c)
|
||||
} else {
|
||||
c.make_ascii_uppercase();
|
||||
format!("capital {} ... ", c)
|
||||
}
|
||||
} else {
|
||||
format!("{} ...", c)
|
||||
};
|
||||
|
||||
built_text.push_str(&new_str);
|
||||
}
|
||||
|
||||
espeak_wav_base64(&built_text)
|
||||
}
|
||||
|
||||
pub(crate) fn espeak_wav_base64(text: &str) -> Result<String, LemmyError> {
|
||||
// Make a temp file path
|
||||
let uuid = uuid::Uuid::new_v4().to_string();
|
||||
let file_path = format!(
|
||||
"{}/lemmy_espeak_{}.wav",
|
||||
env::temp_dir().to_string_lossy(),
|
||||
&uuid
|
||||
);
|
||||
|
||||
// Write the wav file
|
||||
Command::new("espeak")
|
||||
.arg("-w")
|
||||
.arg(&file_path)
|
||||
.arg(text)
|
||||
.status()?;
|
||||
|
||||
// Read the wav file bytes
|
||||
let bytes = std::fs::read(&file_path)?;
|
||||
|
||||
// Delete the file
|
||||
std::fs::remove_file(file_path)?;
|
||||
|
||||
// Convert to base64
|
||||
let base64 = base64::encode(bytes);
|
||||
|
||||
Ok(base64)
|
||||
}
|
||||
|
||||
/// Checks the password length
|
||||
pub(crate) fn password_length_check(pass: &str) -> Result<(), LemmyError> {
|
||||
if pass.len() > 60 {
|
||||
Err(ApiError::err("invalid_password").into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{captcha_espeak_wav_base64, check_validator_time};
|
||||
use lemmy_db_queries::{establish_unpooled_connection, source::local_user::LocalUser_, Crud};
|
||||
use lemmy_db_schema::source::{
|
||||
local_user::{LocalUser, LocalUserForm},
|
||||
person::{Person, PersonForm},
|
||||
};
|
||||
use lemmy_utils::claims::Claims;
|
||||
|
||||
#[test]
|
||||
fn test_should_not_validate_user_token_after_password_change() {
|
||||
let conn = establish_unpooled_connection();
|
||||
|
||||
let new_person = PersonForm {
|
||||
name: "Gerry9812".into(),
|
||||
preferred_username: None,
|
||||
avatar: None,
|
||||
banner: None,
|
||||
banned: None,
|
||||
deleted: None,
|
||||
published: None,
|
||||
updated: None,
|
||||
actor_id: None,
|
||||
bio: None,
|
||||
local: None,
|
||||
private_key: None,
|
||||
public_key: None,
|
||||
last_refreshed_at: None,
|
||||
inbox_url: None,
|
||||
shared_inbox_url: None,
|
||||
};
|
||||
|
||||
let inserted_person = Person::create(&conn, &new_person).unwrap();
|
||||
|
||||
let local_user_form = LocalUserForm {
|
||||
person_id: inserted_person.id,
|
||||
email: None,
|
||||
matrix_user_id: None,
|
||||
password_encrypted: "123456".to_string(),
|
||||
admin: None,
|
||||
show_nsfw: None,
|
||||
theme: None,
|
||||
default_sort_type: None,
|
||||
default_listing_type: None,
|
||||
lang: None,
|
||||
show_avatars: None,
|
||||
send_notifications_to_email: None,
|
||||
};
|
||||
|
||||
let inserted_local_user = LocalUser::create(&conn, &local_user_form).unwrap();
|
||||
|
||||
let jwt = Claims::jwt(inserted_local_user.id.0).unwrap();
|
||||
let claims = Claims::decode(&jwt).unwrap().claims;
|
||||
let check = check_validator_time(&inserted_local_user.validator_time, &claims);
|
||||
assert!(check.is_ok());
|
||||
|
||||
// The check should fail, since the validator time is now newer than the jwt issue time
|
||||
let updated_local_user =
|
||||
LocalUser::update_password(&conn, inserted_local_user.id, &"password111").unwrap();
|
||||
let check_after = check_validator_time(&updated_local_user.validator_time, &claims);
|
||||
assert!(check_after.is_err());
|
||||
|
||||
let num_deleted = Person::delete(&conn, inserted_person.id).unwrap();
|
||||
assert_eq!(1, num_deleted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_espeak() {
|
||||
assert!(captcha_espeak_wav_base64("WxRt2l").is_ok())
|
||||
}
|
||||
}
|
1478
crates/api/src/local_user.rs
Normal file
1478
crates/api/src/local_user.rs
Normal file
File diff suppressed because it is too large
Load diff
954
crates/api/src/post.rs
Normal file
954
crates/api/src/post.rs
Normal file
|
@ -0,0 +1,954 @@
|
|||
use crate::{
|
||||
check_community_ban,
|
||||
check_downvotes_enabled,
|
||||
collect_moderated_communities,
|
||||
get_local_user_view_from_jwt,
|
||||
get_local_user_view_from_jwt_opt,
|
||||
is_mod_or_admin,
|
||||
Perform,
|
||||
};
|
||||
use actix_web::web::Data;
|
||||
use lemmy_api_structs::{blocking, post::*};
|
||||
use lemmy_apub::{generate_apub_endpoint, ApubLikeableType, ApubObjectType, EndpointType};
|
||||
use lemmy_db_queries::{
|
||||
source::post::Post_,
|
||||
Crud,
|
||||
Likeable,
|
||||
ListingType,
|
||||
Reportable,
|
||||
Saveable,
|
||||
SortType,
|
||||
};
|
||||
use lemmy_db_schema::{
|
||||
naive_now,
|
||||
source::{
|
||||
moderator::*,
|
||||
post::*,
|
||||
post_report::{PostReport, PostReportForm},
|
||||
},
|
||||
};
|
||||
use lemmy_db_views::{
|
||||
comment_view::CommentQueryBuilder,
|
||||
post_report_view::{PostReportQueryBuilder, PostReportView},
|
||||
post_view::{PostQueryBuilder, PostView},
|
||||
};
|
||||
use lemmy_db_views_actor::{
|
||||
community_moderator_view::CommunityModeratorView,
|
||||
community_view::CommunityView,
|
||||
};
|
||||
use lemmy_utils::{
|
||||
request::fetch_iframely_and_pictrs_data,
|
||||
utils::{check_slurs, check_slurs_opt, is_valid_post_title},
|
||||
ApiError,
|
||||
ConnectionId,
|
||||
LemmyError,
|
||||
};
|
||||
use lemmy_websocket::{
|
||||
messages::{GetPostUsersOnline, SendModRoomMessage, SendPost, SendUserRoomMessage},
|
||||
LemmyContext,
|
||||
UserOperation,
|
||||
};
|
||||
use std::str::FromStr;
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl Perform for CreatePost {
|
||||
type Response = PostResponse;
|
||||
|
||||
async fn perform(
|
||||
&self,
|
||||
context: &Data<LemmyContext>,
|
||||
websocket_id: Option<ConnectionId>,
|
||||
) -> Result<PostResponse, LemmyError> {
|
||||
let data: &CreatePost = &self;
|
||||
let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
|
||||
|
||||
check_slurs(&data.name)?;
|
||||
check_slurs_opt(&data.body)?;
|
||||
|
||||
if !is_valid_post_title(&data.name) {
|
||||
return Err(ApiError::err("invalid_post_title").into());
|
||||
}
|
||||
|
||||
check_community_ban(local_user_view.person.id, data.community_id, context.pool()).await?;
|
||||
|
||||
// Fetch Iframely and pictrs cached image
|
||||
let data_url = data.url.as_ref();
|
||||
let (iframely_title, iframely_description, iframely_html, pictrs_thumbnail) =
|
||||
fetch_iframely_and_pictrs_data(context.client(), data_url).await;
|
||||
|
||||
let post_form = PostForm {
|
||||
name: data.name.trim().to_owned(),
|
||||
url: data_url.map(|u| u.to_owned().into()),
|
||||
body: data.body.to_owned(),
|
||||
community_id: data.community_id,
|
||||
creator_id: local_user_view.person.id,
|
||||
removed: None,
|
||||
deleted: None,
|
||||
nsfw: data.nsfw,
|
||||
locked: None,
|
||||
stickied: None,
|
||||
updated: None,
|
||||
embed_title: iframely_title,
|
||||
embed_description: iframely_description,
|
||||
embed_html: iframely_html,
|
||||
thumbnail_url: pictrs_thumbnail.map(|u| u.into()),
|
||||
ap_id: None,
|
||||
local: true,
|
||||
published: None,
|
||||
};
|
||||
|
||||
let inserted_post =
|
||||
match blocking(context.pool(), move |conn| Post::create(conn, &post_form)).await? {
|
||||
Ok(post) => post,
|
||||
Err(e) => {
|
||||
let err_type = if e.to_string() == "value too long for type character varying(200)" {
|
||||
"post_title_too_long"
|
||||
} else {
|
||||
"couldnt_create_post"
|
||||
};
|
||||
|
||||
return Err(ApiError::err(err_type).into());
|
||||
}
|
||||
};
|
||||
|
||||
let inserted_post_id = inserted_post.id;
|
||||
let updated_post = match blocking(context.pool(), move |conn| -> Result<Post, LemmyError> {
|
||||
let apub_id = generate_apub_endpoint(EndpointType::Post, &inserted_post_id.to_string())?;
|
||||
Ok(Post::update_ap_id(conn, inserted_post_id, apub_id)?)
|
||||
})
|
||||
.await?
|
||||
{
|
||||
Ok(post) => post,
|
||||
Err(_e) => return Err(ApiError::err("couldnt_create_post").into()),
|
||||
};
|
||||
|
||||
updated_post
|
||||
.send_create(&local_user_view.person, context)
|
||||
.await?;
|
||||
|
||||
// They like their own post by default
|
||||
let like_form = PostLikeForm {
|
||||
post_id: inserted_post.id,
|
||||
person_id: local_user_view.person.id,
|
||||
score: 1,
|
||||
};
|
||||
|
||||
let like = move |conn: &'_ _| PostLike::like(conn, &like_form);
|
||||
if blocking(context.pool(), like).await?.is_err() {
|
||||
return Err(ApiError::err("couldnt_like_post").into());
|
||||
}
|
||||
|
||||
updated_post
|
||||
.send_like(&local_user_view.person, context)
|
||||
.await?;
|
||||
|
||||
// Refetch the view
|
||||
let inserted_post_id = inserted_post.id;
|
||||
let post_view = match blocking(context.pool(), move |conn| {
|
||||
PostView::read(conn, inserted_post_id, Some(local_user_view.person.id))
|
||||
})
|
||||
.await?
|
||||
{
|
||||
Ok(post) => post,
|
||||
Err(_e) => return Err(ApiError::err("couldnt_find_post").into()),
|
||||
};
|
||||
|
||||
let res = PostResponse { post_view };
|
||||
|
||||
context.chat_server().do_send(SendPost {
|
||||
op: UserOperation::CreatePost,
|
||||
post: res.clone(),
|
||||
websocket_id,
|
||||
});
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl Perform for GetPost {
|
||||
type Response = GetPostResponse;
|
||||
|
||||
async fn perform(
|
||||
&self,
|
||||
context: &Data<LemmyContext>,
|
||||
_websocket_id: Option<ConnectionId>,
|
||||
) -> Result<GetPostResponse, LemmyError> {
|
||||
let data: &GetPost = &self;
|
||||
let local_user_view = get_local_user_view_from_jwt_opt(&data.auth, context.pool()).await?;
|
||||
let person_id = local_user_view.map(|u| u.person.id);
|
||||
|
||||
let id = data.id;
|
||||
let post_view = match blocking(context.pool(), move |conn| {
|
||||
PostView::read(conn, id, person_id)
|
||||
})
|
||||
.await?
|
||||
{
|
||||
Ok(post) => post,
|
||||
Err(_e) => return Err(ApiError::err("couldnt_find_post").into()),
|
||||
};
|
||||
|
||||
let id = data.id;
|
||||
let comments = blocking(context.pool(), move |conn| {
|
||||
CommentQueryBuilder::create(conn)
|
||||
.my_person_id(person_id)
|
||||
.post_id(id)
|
||||
.limit(9999)
|
||||
.list()
|
||||
})
|
||||
.await??;
|
||||
|
||||
let community_id = post_view.community.id;
|
||||
let moderators = blocking(context.pool(), move |conn| {
|
||||
CommunityModeratorView::for_community(conn, community_id)
|
||||
})
|
||||
.await??;
|
||||
|
||||
// Necessary for the sidebar
|
||||
let community_view = match blocking(context.pool(), move |conn| {
|
||||
CommunityView::read(conn, community_id, person_id)
|
||||
})
|
||||
.await?
|
||||
{
|
||||
Ok(community) => community,
|
||||
Err(_e) => return Err(ApiError::err("couldnt_find_community").into()),
|
||||
};
|
||||
|
||||
let online = context
|
||||
.chat_server()
|
||||
.send(GetPostUsersOnline { post_id: data.id })
|
||||
.await
|
||||
.unwrap_or(1);
|
||||
|
||||
// Return the jwt
|
||||
Ok(GetPostResponse {
|
||||
post_view,
|
||||
community_view,
|
||||
comments,
|
||||
moderators,
|
||||
online,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl Perform for GetPosts {
|
||||
type Response = GetPostsResponse;
|
||||
|
||||
async fn perform(
|
||||
&self,
|
||||
context: &Data<LemmyContext>,
|
||||
_websocket_id: Option<ConnectionId>,
|
||||
) -> Result<GetPostsResponse, LemmyError> {
|
||||
let data: &GetPosts = &self;
|
||||
let local_user_view = get_local_user_view_from_jwt_opt(&data.auth, context.pool()).await?;
|
||||
|
||||
let person_id = match &local_user_view {
|
||||
Some(uv) => Some(uv.person.id),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let show_nsfw = match &local_user_view {
|
||||
Some(uv) => uv.local_user.show_nsfw,
|
||||
None => false,
|
||||
};
|
||||
|
||||
let type_ = ListingType::from_str(&data.type_)?;
|
||||
let sort = SortType::from_str(&data.sort)?;
|
||||
|
||||
let page = data.page;
|
||||
let limit = data.limit;
|
||||
let community_id = data.community_id;
|
||||
let community_name = data.community_name.to_owned();
|
||||
let posts = match blocking(context.pool(), move |conn| {
|
||||
PostQueryBuilder::create(conn)
|
||||
.listing_type(&type_)
|
||||
.sort(&sort)
|
||||
.show_nsfw(show_nsfw)
|
||||
.community_id(community_id)
|
||||
.community_name(community_name)
|
||||
.my_person_id(person_id)
|
||||
.page(page)
|
||||
.limit(limit)
|
||||
.list()
|
||||
})
|
||||
.await?
|
||||
{
|
||||
Ok(posts) => posts,
|
||||
Err(_e) => return Err(ApiError::err("couldnt_get_posts").into()),
|
||||
};
|
||||
|
||||
Ok(GetPostsResponse { posts })
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl Perform for CreatePostLike {
|
||||
type Response = PostResponse;
|
||||
|
||||
async fn perform(
|
||||
&self,
|
||||
context: &Data<LemmyContext>,
|
||||
websocket_id: Option<ConnectionId>,
|
||||
) -> Result<PostResponse, LemmyError> {
|
||||
let data: &CreatePostLike = &self;
|
||||
let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
|
||||
|
||||
// Don't do a downvote if site has downvotes disabled
|
||||
check_downvotes_enabled(data.score, context.pool()).await?;
|
||||
|
||||
// Check for a community ban
|
||||
let post_id = data.post_id;
|
||||
let post = blocking(context.pool(), move |conn| Post::read(conn, post_id)).await??;
|
||||
|
||||
check_community_ban(local_user_view.person.id, post.community_id, context.pool()).await?;
|
||||
|
||||
let like_form = PostLikeForm {
|
||||
post_id: data.post_id,
|
||||
person_id: local_user_view.person.id,
|
||||
score: data.score,
|
||||
};
|
||||
|
||||
// Remove any likes first
|
||||
let person_id = local_user_view.person.id;
|
||||
blocking(context.pool(), move |conn| {
|
||||
PostLike::remove(conn, person_id, post_id)
|
||||
})
|
||||
.await??;
|
||||
|
||||
// Only add the like if the score isnt 0
|
||||
let do_add = like_form.score != 0 && (like_form.score == 1 || like_form.score == -1);
|
||||
if do_add {
|
||||
let like_form2 = like_form.clone();
|
||||
let like = move |conn: &'_ _| PostLike::like(conn, &like_form2);
|
||||
if blocking(context.pool(), like).await?.is_err() {
|
||||
return Err(ApiError::err("couldnt_like_post").into());
|
||||
}
|
||||
|
||||
if like_form.score == 1 {
|
||||
post.send_like(&local_user_view.person, context).await?;
|
||||
} else if like_form.score == -1 {
|
||||
post.send_dislike(&local_user_view.person, context).await?;
|
||||
}
|
||||
} else {
|
||||
post
|
||||
.send_undo_like(&local_user_view.person, context)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let post_id = data.post_id;
|
||||
let person_id = local_user_view.person.id;
|
||||
let post_view = match blocking(context.pool(), move |conn| {
|
||||
PostView::read(conn, post_id, Some(person_id))
|
||||
})
|
||||
.await?
|
||||
{
|
||||
Ok(post) => post,
|
||||
Err(_e) => return Err(ApiError::err("couldnt_find_post").into()),
|
||||
};
|
||||
|
||||
let res = PostResponse { post_view };
|
||||
|
||||
context.chat_server().do_send(SendPost {
|
||||
op: UserOperation::CreatePostLike,
|
||||
post: res.clone(),
|
||||
websocket_id,
|
||||
});
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl Perform for EditPost {
|
||||
type Response = PostResponse;
|
||||
|
||||
async fn perform(
|
||||
&self,
|
||||
context: &Data<LemmyContext>,
|
||||
websocket_id: Option<ConnectionId>,
|
||||
) -> Result<PostResponse, LemmyError> {
|
||||
let data: &EditPost = &self;
|
||||
let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
|
||||
|
||||
check_slurs(&data.name)?;
|
||||
check_slurs_opt(&data.body)?;
|
||||
|
||||
if !is_valid_post_title(&data.name) {
|
||||
return Err(ApiError::err("invalid_post_title").into());
|
||||
}
|
||||
|
||||
let post_id = data.post_id;
|
||||
let orig_post = blocking(context.pool(), move |conn| Post::read(conn, post_id)).await??;
|
||||
|
||||
check_community_ban(
|
||||
local_user_view.person.id,
|
||||
orig_post.community_id,
|
||||
context.pool(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Verify that only the creator can edit
|
||||
if !Post::is_post_creator(local_user_view.person.id, orig_post.creator_id) {
|
||||
return Err(ApiError::err("no_post_edit_allowed").into());
|
||||
}
|
||||
|
||||
// Fetch Iframely and Pictrs cached image
|
||||
let data_url = data.url.as_ref();
|
||||
let (iframely_title, iframely_description, iframely_html, pictrs_thumbnail) =
|
||||
fetch_iframely_and_pictrs_data(context.client(), data_url).await;
|
||||
|
||||
let post_form = PostForm {
|
||||
name: data.name.trim().to_owned(),
|
||||
url: data_url.map(|u| u.to_owned().into()),
|
||||
body: data.body.to_owned(),
|
||||
nsfw: data.nsfw,
|
||||
creator_id: orig_post.creator_id.to_owned(),
|
||||
community_id: orig_post.community_id,
|
||||
removed: Some(orig_post.removed),
|
||||
deleted: Some(orig_post.deleted),
|
||||
locked: Some(orig_post.locked),
|
||||
stickied: Some(orig_post.stickied),
|
||||
updated: Some(naive_now()),
|
||||
embed_title: iframely_title,
|
||||
embed_description: iframely_description,
|
||||
embed_html: iframely_html,
|
||||
thumbnail_url: pictrs_thumbnail.map(|u| u.into()),
|
||||
ap_id: Some(orig_post.ap_id),
|
||||
local: orig_post.local,
|
||||
published: None,
|
||||
};
|
||||
|
||||
let post_id = data.post_id;
|
||||
let res = blocking(context.pool(), move |conn| {
|
||||
Post::update(conn, post_id, &post_form)
|
||||
})
|
||||
.await?;
|
||||
let updated_post: Post = match res {
|
||||
Ok(post) => post,
|
||||
Err(e) => {
|
||||
let err_type = if e.to_string() == "value too long for type character varying(200)" {
|
||||
"post_title_too_long"
|
||||
} else {
|
||||
"couldnt_update_post"
|
||||
};
|
||||
|
||||
return Err(ApiError::err(err_type).into());
|
||||
}
|
||||
};
|
||||
|
||||
// Send apub update
|
||||
updated_post
|
||||
.send_update(&local_user_view.person, context)
|
||||
.await?;
|
||||
|
||||
let post_id = data.post_id;
|
||||
let post_view = blocking(context.pool(), move |conn| {
|
||||
PostView::read(conn, post_id, Some(local_user_view.person.id))
|
||||
})
|
||||
.await??;
|
||||
|
||||
let res = PostResponse { post_view };
|
||||
|
||||
context.chat_server().do_send(SendPost {
|
||||
op: UserOperation::EditPost,
|
||||
post: res.clone(),
|
||||
websocket_id,
|
||||
});
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl Perform for DeletePost {
|
||||
type Response = PostResponse;
|
||||
|
||||
async fn perform(
|
||||
&self,
|
||||
context: &Data<LemmyContext>,
|
||||
websocket_id: Option<ConnectionId>,
|
||||
) -> Result<PostResponse, LemmyError> {
|
||||
let data: &DeletePost = &self;
|
||||
let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
|
||||
|
||||
let post_id = data.post_id;
|
||||
let orig_post = blocking(context.pool(), move |conn| Post::read(conn, post_id)).await??;
|
||||
|
||||
check_community_ban(
|
||||
local_user_view.person.id,
|
||||
orig_post.community_id,
|
||||
context.pool(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Verify that only the creator can delete
|
||||
if !Post::is_post_creator(local_user_view.person.id, orig_post.creator_id) {
|
||||
return Err(ApiError::err("no_post_edit_allowed").into());
|
||||
}
|
||||
|
||||
// Update the post
|
||||
let post_id = data.post_id;
|
||||
let deleted = data.deleted;
|
||||
let updated_post = blocking(context.pool(), move |conn| {
|
||||
Post::update_deleted(conn, post_id, deleted)
|
||||
})
|
||||
.await??;
|
||||
|
||||
// apub updates
|
||||
if deleted {
|
||||
updated_post
|
||||
.send_delete(&local_user_view.person, context)
|
||||
.await?;
|
||||
} else {
|
||||
updated_post
|
||||
.send_undo_delete(&local_user_view.person, context)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Refetch the post
|
||||
let post_id = data.post_id;
|
||||
let post_view = blocking(context.pool(), move |conn| {
|
||||
PostView::read(conn, post_id, Some(local_user_view.person.id))
|
||||
})
|
||||
.await??;
|
||||
|
||||
let res = PostResponse { post_view };
|
||||
|
||||
context.chat_server().do_send(SendPost {
|
||||
op: UserOperation::DeletePost,
|
||||
post: res.clone(),
|
||||
websocket_id,
|
||||
});
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl Perform for RemovePost {
|
||||
type Response = PostResponse;
|
||||
|
||||
async fn perform(
|
||||
&self,
|
||||
context: &Data<LemmyContext>,
|
||||
websocket_id: Option<ConnectionId>,
|
||||
) -> Result<PostResponse, LemmyError> {
|
||||
let data: &RemovePost = &self;
|
||||
let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
|
||||
|
||||
let post_id = data.post_id;
|
||||
let orig_post = blocking(context.pool(), move |conn| Post::read(conn, post_id)).await??;
|
||||
|
||||
check_community_ban(
|
||||
local_user_view.person.id,
|
||||
orig_post.community_id,
|
||||
context.pool(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Verify that only the mods can remove
|
||||
is_mod_or_admin(
|
||||
context.pool(),
|
||||
local_user_view.person.id,
|
||||
orig_post.community_id,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Update the post
|
||||
let post_id = data.post_id;
|
||||
let removed = data.removed;
|
||||
let updated_post = blocking(context.pool(), move |conn| {
|
||||
Post::update_removed(conn, post_id, removed)
|
||||
})
|
||||
.await??;
|
||||
|
||||
// Mod tables
|
||||
let form = ModRemovePostForm {
|
||||
mod_person_id: local_user_view.person.id,
|
||||
post_id: data.post_id,
|
||||
removed: Some(removed),
|
||||
reason: data.reason.to_owned(),
|
||||
};
|
||||
blocking(context.pool(), move |conn| {
|
||||
ModRemovePost::create(conn, &form)
|
||||
})
|
||||
.await??;
|
||||
|
||||
// apub updates
|
||||
if removed {
|
||||
updated_post
|
||||
.send_remove(&local_user_view.person, context)
|
||||
.await?;
|
||||
} else {
|
||||
updated_post
|
||||
.send_undo_remove(&local_user_view.person, context)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Refetch the post
|
||||
let post_id = data.post_id;
|
||||
let person_id = local_user_view.person.id;
|
||||
let post_view = blocking(context.pool(), move |conn| {
|
||||
PostView::read(conn, post_id, Some(person_id))
|
||||
})
|
||||
.await??;
|
||||
|
||||
let res = PostResponse { post_view };
|
||||
|
||||
context.chat_server().do_send(SendPost {
|
||||
op: UserOperation::RemovePost,
|
||||
post: res.clone(),
|
||||
websocket_id,
|
||||
});
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl Perform for LockPost {
|
||||
type Response = PostResponse;
|
||||
|
||||
async fn perform(
|
||||
&self,
|
||||
context: &Data<LemmyContext>,
|
||||
websocket_id: Option<ConnectionId>,
|
||||
) -> Result<PostResponse, LemmyError> {
|
||||
let data: &LockPost = &self;
|
||||
let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
|
||||
|
||||
let post_id = data.post_id;
|
||||
let orig_post = blocking(context.pool(), move |conn| Post::read(conn, post_id)).await??;
|
||||
|
||||
check_community_ban(
|
||||
local_user_view.person.id,
|
||||
orig_post.community_id,
|
||||
context.pool(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Verify that only the mods can lock
|
||||
is_mod_or_admin(
|
||||
context.pool(),
|
||||
local_user_view.person.id,
|
||||
orig_post.community_id,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Update the post
|
||||
let post_id = data.post_id;
|
||||
let locked = data.locked;
|
||||
let updated_post = blocking(context.pool(), move |conn| {
|
||||
Post::update_locked(conn, post_id, locked)
|
||||
})
|
||||
.await??;
|
||||
|
||||
// Mod tables
|
||||
let form = ModLockPostForm {
|
||||
mod_person_id: local_user_view.person.id,
|
||||
post_id: data.post_id,
|
||||
locked: Some(locked),
|
||||
};
|
||||
blocking(context.pool(), move |conn| ModLockPost::create(conn, &form)).await??;
|
||||
|
||||
// apub updates
|
||||
updated_post
|
||||
.send_update(&local_user_view.person, context)
|
||||
.await?;
|
||||
|
||||
// Refetch the post
|
||||
let post_id = data.post_id;
|
||||
let post_view = blocking(context.pool(), move |conn| {
|
||||
PostView::read(conn, post_id, Some(local_user_view.person.id))
|
||||
})
|
||||
.await??;
|
||||
|
||||
let res = PostResponse { post_view };
|
||||
|
||||
context.chat_server().do_send(SendPost {
|
||||
op: UserOperation::LockPost,
|
||||
post: res.clone(),
|
||||
websocket_id,
|
||||
});
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl Perform for StickyPost {
|
||||
type Response = PostResponse;
|
||||
|
||||
async fn perform(
|
||||
&self,
|
||||
context: &Data<LemmyContext>,
|
||||
websocket_id: Option<ConnectionId>,
|
||||
) -> Result<PostResponse, LemmyError> {
|
||||
let data: &StickyPost = &self;
|
||||
let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
|
||||
|
||||
let post_id = data.post_id;
|
||||
let orig_post = blocking(context.pool(), move |conn| Post::read(conn, post_id)).await??;
|
||||
|
||||
check_community_ban(
|
||||
local_user_view.person.id,
|
||||
orig_post.community_id,
|
||||
context.pool(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Verify that only the mods can sticky
|
||||
is_mod_or_admin(
|
||||
context.pool(),
|
||||
local_user_view.person.id,
|
||||
orig_post.community_id,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Update the post
|
||||
let post_id = data.post_id;
|
||||
let stickied = data.stickied;
|
||||
let updated_post = blocking(context.pool(), move |conn| {
|
||||
Post::update_stickied(conn, post_id, stickied)
|
||||
})
|
||||
.await??;
|
||||
|
||||
// Mod tables
|
||||
let form = ModStickyPostForm {
|
||||
mod_person_id: local_user_view.person.id,
|
||||
post_id: data.post_id,
|
||||
stickied: Some(stickied),
|
||||
};
|
||||
blocking(context.pool(), move |conn| {
|
||||
ModStickyPost::create(conn, &form)
|
||||
})
|
||||
.await??;
|
||||
|
||||
// Apub updates
|
||||
// TODO stickied should pry work like locked for ease of use
|
||||
updated_post
|
||||
.send_update(&local_user_view.person, context)
|
||||
.await?;
|
||||
|
||||
// Refetch the post
|
||||
let post_id = data.post_id;
|
||||
let post_view = blocking(context.pool(), move |conn| {
|
||||
PostView::read(conn, post_id, Some(local_user_view.person.id))
|
||||
})
|
||||
.await??;
|
||||
|
||||
let res = PostResponse { post_view };
|
||||
|
||||
context.chat_server().do_send(SendPost {
|
||||
op: UserOperation::StickyPost,
|
||||
post: res.clone(),
|
||||
websocket_id,
|
||||
});
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl Perform for SavePost {
|
||||
type Response = PostResponse;
|
||||
|
||||
async fn perform(
|
||||
&self,
|
||||
context: &Data<LemmyContext>,
|
||||
_websocket_id: Option<ConnectionId>,
|
||||
) -> Result<PostResponse, LemmyError> {
|
||||
let data: &SavePost = &self;
|
||||
let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
|
||||
|
||||
let post_saved_form = PostSavedForm {
|
||||
post_id: data.post_id,
|
||||
person_id: local_user_view.person.id,
|
||||
};
|
||||
|
||||
if data.save {
|
||||
let save = move |conn: &'_ _| PostSaved::save(conn, &post_saved_form);
|
||||
if blocking(context.pool(), save).await?.is_err() {
|
||||
return Err(ApiError::err("couldnt_save_post").into());
|
||||
}
|
||||
} else {
|
||||
let unsave = move |conn: &'_ _| PostSaved::unsave(conn, &post_saved_form);
|
||||
if blocking(context.pool(), unsave).await?.is_err() {
|
||||
return Err(ApiError::err("couldnt_save_post").into());
|
||||
}
|
||||
}
|
||||
|
||||
let post_id = data.post_id;
|
||||
let person_id = local_user_view.person.id;
|
||||
let post_view = blocking(context.pool(), move |conn| {
|
||||
PostView::read(conn, post_id, Some(person_id))
|
||||
})
|
||||
.await??;
|
||||
|
||||
Ok(PostResponse { post_view })
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a post report and notifies the moderators of the community
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl Perform for CreatePostReport {
|
||||
type Response = CreatePostReportResponse;
|
||||
|
||||
async fn perform(
|
||||
&self,
|
||||
context: &Data<LemmyContext>,
|
||||
websocket_id: Option<ConnectionId>,
|
||||
) -> Result<CreatePostReportResponse, LemmyError> {
|
||||
let data: &CreatePostReport = &self;
|
||||
let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
|
||||
|
||||
// check size of report and check for whitespace
|
||||
let reason = data.reason.trim();
|
||||
if reason.is_empty() {
|
||||
return Err(ApiError::err("report_reason_required").into());
|
||||
}
|
||||
if reason.chars().count() > 1000 {
|
||||
return Err(ApiError::err("report_too_long").into());
|
||||
}
|
||||
|
||||
let person_id = local_user_view.person.id;
|
||||
let post_id = data.post_id;
|
||||
let post_view = blocking(context.pool(), move |conn| {
|
||||
PostView::read(&conn, post_id, None)
|
||||
})
|
||||
.await??;
|
||||
|
||||
check_community_ban(person_id, post_view.community.id, context.pool()).await?;
|
||||
|
||||
let report_form = PostReportForm {
|
||||
creator_id: person_id,
|
||||
post_id,
|
||||
original_post_name: post_view.post.name,
|
||||
original_post_url: post_view.post.url,
|
||||
original_post_body: post_view.post.body,
|
||||
reason: data.reason.to_owned(),
|
||||
};
|
||||
|
||||
let report = match blocking(context.pool(), move |conn| {
|
||||
PostReport::report(conn, &report_form)
|
||||
})
|
||||
.await?
|
||||
{
|
||||
Ok(report) => report,
|
||||
Err(_e) => return Err(ApiError::err("couldnt_create_report").into()),
|
||||
};
|
||||
|
||||
let res = CreatePostReportResponse { success: true };
|
||||
|
||||
context.chat_server().do_send(SendUserRoomMessage {
|
||||
op: UserOperation::CreatePostReport,
|
||||
response: res.clone(),
|
||||
local_recipient_id: local_user_view.local_user.id,
|
||||
websocket_id,
|
||||
});
|
||||
|
||||
context.chat_server().do_send(SendModRoomMessage {
|
||||
op: UserOperation::CreatePostReport,
|
||||
response: report,
|
||||
community_id: post_view.community.id,
|
||||
websocket_id,
|
||||
});
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves or unresolves a post report and notifies the moderators of the community
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl Perform for ResolvePostReport {
|
||||
type Response = ResolvePostReportResponse;
|
||||
|
||||
async fn perform(
|
||||
&self,
|
||||
context: &Data<LemmyContext>,
|
||||
websocket_id: Option<ConnectionId>,
|
||||
) -> Result<ResolvePostReportResponse, LemmyError> {
|
||||
let data: &ResolvePostReport = &self;
|
||||
let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
|
||||
|
||||
let report_id = data.report_id;
|
||||
let report = blocking(context.pool(), move |conn| {
|
||||
PostReportView::read(&conn, report_id)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let person_id = local_user_view.person.id;
|
||||
is_mod_or_admin(context.pool(), person_id, report.community.id).await?;
|
||||
|
||||
let resolved = data.resolved;
|
||||
let resolve_fun = move |conn: &'_ _| {
|
||||
if resolved {
|
||||
PostReport::resolve(conn, report_id, person_id)
|
||||
} else {
|
||||
PostReport::unresolve(conn, report_id, person_id)
|
||||
}
|
||||
};
|
||||
|
||||
let res = ResolvePostReportResponse {
|
||||
report_id,
|
||||
resolved: true,
|
||||
};
|
||||
|
||||
if blocking(context.pool(), resolve_fun).await?.is_err() {
|
||||
return Err(ApiError::err("couldnt_resolve_report").into());
|
||||
};
|
||||
|
||||
context.chat_server().do_send(SendModRoomMessage {
|
||||
op: UserOperation::ResolvePostReport,
|
||||
response: res.clone(),
|
||||
community_id: report.community.id,
|
||||
websocket_id,
|
||||
});
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
|
||||
/// Lists post reports for a community if an id is supplied
|
||||
/// or returns all post reports for communities a user moderates
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl Perform for ListPostReports {
|
||||
type Response = ListPostReportsResponse;
|
||||
|
||||
async fn perform(
|
||||
&self,
|
||||
context: &Data<LemmyContext>,
|
||||
websocket_id: Option<ConnectionId>,
|
||||
) -> Result<ListPostReportsResponse, LemmyError> {
|
||||
let data: &ListPostReports = &self;
|
||||
let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
|
||||
|
||||
let person_id = local_user_view.person.id;
|
||||
let community_id = data.community;
|
||||
let community_ids =
|
||||
collect_moderated_communities(person_id, community_id, context.pool()).await?;
|
||||
|
||||
let page = data.page;
|
||||
let limit = data.limit;
|
||||
let posts = blocking(context.pool(), move |conn| {
|
||||
PostReportQueryBuilder::create(conn)
|
||||
.community_ids(community_ids)
|
||||
.page(page)
|
||||
.limit(limit)
|
||||
.list()
|
||||
})
|
||||
.await??;
|
||||
|
||||
let res = ListPostReportsResponse { posts };
|
||||
|
||||
context.chat_server().do_send(SendUserRoomMessage {
|
||||
op: UserOperation::ListPostReports,
|
||||
response: res.clone(),
|
||||
local_recipient_id: local_user_view.local_user.id,
|
||||
websocket_id,
|
||||
});
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
}
|
223
crates/api/src/routes.rs
Normal file
223
crates/api/src/routes.rs
Normal file
|
@ -0,0 +1,223 @@
|
|||
use crate::Perform;
|
||||
use actix_web::{error::ErrorBadRequest, *};
|
||||
use lemmy_api_structs::{comment::*, community::*, person::*, post::*, site::*, websocket::*};
|
||||
use lemmy_utils::rate_limit::RateLimit;
|
||||
use lemmy_websocket::{routes::chat_route, LemmyContext};
|
||||
use serde::Deserialize;
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig, rate_limit: &RateLimit) {
|
||||
cfg.service(
|
||||
web::scope("/api/v2")
|
||||
// Websockets
|
||||
.service(web::resource("/ws").to(chat_route))
|
||||
// Site
|
||||
.service(
|
||||
web::scope("/site")
|
||||
.wrap(rate_limit.message())
|
||||
.route("", web::get().to(route_get::<GetSite>))
|
||||
// Admin Actions
|
||||
.route("", web::post().to(route_post::<CreateSite>))
|
||||
.route("", web::put().to(route_post::<EditSite>))
|
||||
.route("/transfer", web::post().to(route_post::<TransferSite>))
|
||||
.route("/config", web::get().to(route_get::<GetSiteConfig>))
|
||||
.route("/config", web::put().to(route_post::<SaveSiteConfig>)),
|
||||
)
|
||||
.service(
|
||||
web::resource("/modlog")
|
||||
.wrap(rate_limit.message())
|
||||
.route(web::get().to(route_get::<GetModlog>)),
|
||||
)
|
||||
.service(
|
||||
web::resource("/search")
|
||||
.wrap(rate_limit.message())
|
||||
.route(web::get().to(route_get::<Search>)),
|
||||
)
|
||||
// Community
|
||||
.service(
|
||||
web::resource("/community")
|
||||
.guard(guard::Post())
|
||||
.wrap(rate_limit.register())
|
||||
.route(web::post().to(route_post::<CreateCommunity>)),
|
||||
)
|
||||
.service(
|
||||
web::scope("/community")
|
||||
.wrap(rate_limit.message())
|
||||
.route("", web::get().to(route_get::<GetCommunity>))
|
||||
.route("", web::put().to(route_post::<EditCommunity>))
|
||||
.route("/list", web::get().to(route_get::<ListCommunities>))
|
||||
.route("/follow", web::post().to(route_post::<FollowCommunity>))
|
||||
.route("/delete", web::post().to(route_post::<DeleteCommunity>))
|
||||
// Mod Actions
|
||||
.route("/remove", web::post().to(route_post::<RemoveCommunity>))
|
||||
.route("/transfer", web::post().to(route_post::<TransferCommunity>))
|
||||
.route("/ban_user", web::post().to(route_post::<BanFromCommunity>))
|
||||
.route("/mod", web::post().to(route_post::<AddModToCommunity>))
|
||||
.route("/join", web::post().to(route_post::<CommunityJoin>))
|
||||
.route("/mod/join", web::post().to(route_post::<ModJoin>)),
|
||||
)
|
||||
// Post
|
||||
.service(
|
||||
// Handle POST to /post separately to add the post() rate limitter
|
||||
web::resource("/post")
|
||||
.guard(guard::Post())
|
||||
.wrap(rate_limit.post())
|
||||
.route(web::post().to(route_post::<CreatePost>)),
|
||||
)
|
||||
.service(
|
||||
web::scope("/post")
|
||||
.wrap(rate_limit.message())
|
||||
.route("", web::get().to(route_get::<GetPost>))
|
||||
.route("", web::put().to(route_post::<EditPost>))
|
||||
.route("/delete", web::post().to(route_post::<DeletePost>))
|
||||
.route("/remove", web::post().to(route_post::<RemovePost>))
|
||||
.route("/lock", web::post().to(route_post::<LockPost>))
|
||||
.route("/sticky", web::post().to(route_post::<StickyPost>))
|
||||
.route("/list", web::get().to(route_get::<GetPosts>))
|
||||
.route("/like", web::post().to(route_post::<CreatePostLike>))
|
||||
.route("/save", web::put().to(route_post::<SavePost>))
|
||||
.route("/join", web::post().to(route_post::<PostJoin>))
|
||||
.route("/report", web::post().to(route_post::<CreatePostReport>))
|
||||
.route(
|
||||
"/report/resolve",
|
||||
web::put().to(route_post::<ResolvePostReport>),
|
||||
)
|
||||
.route("/report/list", web::get().to(route_get::<ListPostReports>)),
|
||||
)
|
||||
// Comment
|
||||
.service(
|
||||
web::scope("/comment")
|
||||
.wrap(rate_limit.message())
|
||||
.route("", web::post().to(route_post::<CreateComment>))
|
||||
.route("", web::put().to(route_post::<EditComment>))
|
||||
.route("/delete", web::post().to(route_post::<DeleteComment>))
|
||||
.route("/remove", web::post().to(route_post::<RemoveComment>))
|
||||
.route(
|
||||
"/mark_as_read",
|
||||
web::post().to(route_post::<MarkCommentAsRead>),
|
||||
)
|
||||
.route("/like", web::post().to(route_post::<CreateCommentLike>))
|
||||
.route("/save", web::put().to(route_post::<SaveComment>))
|
||||
.route("/list", web::get().to(route_get::<GetComments>))
|
||||
.route("/report", web::post().to(route_post::<CreateCommentReport>))
|
||||
.route(
|
||||
"/report/resolve",
|
||||
web::put().to(route_post::<ResolveCommentReport>),
|
||||
)
|
||||
.route(
|
||||
"/report/list",
|
||||
web::get().to(route_get::<ListCommentReports>),
|
||||
),
|
||||
)
|
||||
// Private Message
|
||||
.service(
|
||||
web::scope("/private_message")
|
||||
.wrap(rate_limit.message())
|
||||
.route("/list", web::get().to(route_get::<GetPrivateMessages>))
|
||||
.route("", web::post().to(route_post::<CreatePrivateMessage>))
|
||||
.route("", web::put().to(route_post::<EditPrivateMessage>))
|
||||
.route(
|
||||
"/delete",
|
||||
web::post().to(route_post::<DeletePrivateMessage>),
|
||||
)
|
||||
.route(
|
||||
"/mark_as_read",
|
||||
web::post().to(route_post::<MarkPrivateMessageAsRead>),
|
||||
),
|
||||
)
|
||||
// User
|
||||
.service(
|
||||
// Account action, I don't like that it's in /user maybe /accounts
|
||||
// Handle /user/register separately to add the register() rate limitter
|
||||
web::resource("/user/register")
|
||||
.guard(guard::Post())
|
||||
.wrap(rate_limit.register())
|
||||
.route(web::post().to(route_post::<Register>)),
|
||||
)
|
||||
// User actions
|
||||
.service(
|
||||
web::scope("/user")
|
||||
.wrap(rate_limit.message())
|
||||
.route("", web::get().to(route_get::<GetPersonDetails>))
|
||||
.route("/mention", web::get().to(route_get::<GetPersonMentions>))
|
||||
.route(
|
||||
"/mention/mark_as_read",
|
||||
web::post().to(route_post::<MarkPersonMentionAsRead>),
|
||||
)
|
||||
.route("/replies", web::get().to(route_get::<GetReplies>))
|
||||
.route(
|
||||
"/followed_communities",
|
||||
web::get().to(route_get::<GetFollowedCommunities>),
|
||||
)
|
||||
.route("/join", web::post().to(route_post::<UserJoin>))
|
||||
// Admin action. I don't like that it's in /user
|
||||
.route("/ban", web::post().to(route_post::<BanPerson>))
|
||||
// Account actions. I don't like that they're in /user maybe /accounts
|
||||
.route("/login", web::post().to(route_post::<Login>))
|
||||
.route("/get_captcha", web::get().to(route_get::<GetCaptcha>))
|
||||
.route(
|
||||
"/delete_account",
|
||||
web::post().to(route_post::<DeleteAccount>),
|
||||
)
|
||||
.route(
|
||||
"/password_reset",
|
||||
web::post().to(route_post::<PasswordReset>),
|
||||
)
|
||||
.route(
|
||||
"/password_change",
|
||||
web::post().to(route_post::<PasswordChange>),
|
||||
)
|
||||
// mark_all_as_read feels off being in this section as well
|
||||
.route(
|
||||
"/mark_all_as_read",
|
||||
web::post().to(route_post::<MarkAllAsRead>),
|
||||
)
|
||||
.route(
|
||||
"/save_user_settings",
|
||||
web::put().to(route_post::<SaveUserSettings>),
|
||||
)
|
||||
.route("/report_count", web::get().to(route_get::<GetReportCount>)),
|
||||
)
|
||||
// Admin Actions
|
||||
.service(
|
||||
web::resource("/admin/add")
|
||||
.wrap(rate_limit.message())
|
||||
.route(web::post().to(route_post::<AddAdmin>)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async fn perform<Request>(
|
||||
data: Request,
|
||||
context: web::Data<LemmyContext>,
|
||||
) -> Result<HttpResponse, Error>
|
||||
where
|
||||
Request: Perform,
|
||||
Request: Send + 'static,
|
||||
{
|
||||
let res = data
|
||||
.perform(&context, None)
|
||||
.await
|
||||
.map(|json| HttpResponse::Ok().json(json))
|
||||
.map_err(ErrorBadRequest)?;
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
async fn route_get<'a, Data>(
|
||||
data: web::Query<Data>,
|
||||
context: web::Data<LemmyContext>,
|
||||
) -> Result<HttpResponse, Error>
|
||||
where
|
||||
Data: Deserialize<'a> + Send + 'static + Perform,
|
||||
{
|
||||
perform::<Data>(data.0, context).await
|
||||
}
|
||||
|
||||
async fn route_post<'a, Data>(
|
||||
data: web::Json<Data>,
|
||||
context: web::Data<LemmyContext>,
|
||||
) -> Result<HttpResponse, Error>
|
||||
where
|
||||
Data: Deserialize<'a> + Send + 'static + Perform,
|
||||
{
|
||||
perform::<Data>(data.0, context).await
|
||||
}
|
598
crates/api/src/site.rs
Normal file
598
crates/api/src/site.rs
Normal file
|
@ -0,0 +1,598 @@
|
|||
use crate::{
|
||||
build_federated_instances,
|
||||
get_local_user_settings_view_from_jwt,
|
||||
get_local_user_settings_view_from_jwt_opt,
|
||||
get_local_user_view_from_jwt,
|
||||
get_local_user_view_from_jwt_opt,
|
||||
is_admin,
|
||||
Perform,
|
||||
};
|
||||
use actix_web::web::Data;
|
||||
use anyhow::Context;
|
||||
use lemmy_api_structs::{blocking, person::Register, site::*};
|
||||
use lemmy_apub::fetcher::search::search_by_apub_id;
|
||||
use lemmy_db_queries::{
|
||||
diesel_option_overwrite_to_url,
|
||||
source::site::Site_,
|
||||
Crud,
|
||||
SearchType,
|
||||
SortType,
|
||||
};
|
||||
use lemmy_db_schema::{
|
||||
naive_now,
|
||||
source::{
|
||||
moderator::*,
|
||||
site::{Site, *},
|
||||
},
|
||||
};
|
||||
use lemmy_db_views::{
|
||||
comment_view::CommentQueryBuilder,
|
||||
post_view::PostQueryBuilder,
|
||||
site_view::SiteView,
|
||||
};
|
||||
use lemmy_db_views_actor::{
|
||||
community_view::CommunityQueryBuilder,
|
||||
person_view::{PersonQueryBuilder, PersonViewSafe},
|
||||
};
|
||||
use lemmy_db_views_moderator::{
|
||||
mod_add_community_view::ModAddCommunityView,
|
||||
mod_add_view::ModAddView,
|
||||
mod_ban_from_community_view::ModBanFromCommunityView,
|
||||
mod_ban_view::ModBanView,
|
||||
mod_lock_post_view::ModLockPostView,
|
||||
mod_remove_comment_view::ModRemoveCommentView,
|
||||
mod_remove_community_view::ModRemoveCommunityView,
|
||||
mod_remove_post_view::ModRemovePostView,
|
||||
mod_sticky_post_view::ModStickyPostView,
|
||||
};
|
||||
use lemmy_utils::{
|
||||
location_info,
|
||||
settings::structs::Settings,
|
||||
utils::{check_slurs, check_slurs_opt},
|
||||
version,
|
||||
ApiError,
|
||||
ConnectionId,
|
||||
LemmyError,
|
||||
};
|
||||
use lemmy_websocket::{
|
||||
messages::{GetUsersOnline, SendAllMessage},
|
||||
LemmyContext,
|
||||
UserOperation,
|
||||
};
|
||||
use log::{debug, info};
|
||||
use std::str::FromStr;
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl Perform for GetModlog {
|
||||
type Response = GetModlogResponse;
|
||||
|
||||
async fn perform(
|
||||
&self,
|
||||
context: &Data<LemmyContext>,
|
||||
_websocket_id: Option<ConnectionId>,
|
||||
) -> Result<GetModlogResponse, LemmyError> {
|
||||
let data: &GetModlog = &self;
|
||||
|
||||
let community_id = data.community_id;
|
||||
let mod_person_id = data.mod_person_id;
|
||||
let page = data.page;
|
||||
let limit = data.limit;
|
||||
let removed_posts = blocking(context.pool(), move |conn| {
|
||||
ModRemovePostView::list(conn, community_id, mod_person_id, page, limit)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let locked_posts = blocking(context.pool(), move |conn| {
|
||||
ModLockPostView::list(conn, community_id, mod_person_id, page, limit)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let stickied_posts = blocking(context.pool(), move |conn| {
|
||||
ModStickyPostView::list(conn, community_id, mod_person_id, page, limit)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let removed_comments = blocking(context.pool(), move |conn| {
|
||||
ModRemoveCommentView::list(conn, community_id, mod_person_id, page, limit)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let banned_from_community = blocking(context.pool(), move |conn| {
|
||||
ModBanFromCommunityView::list(conn, community_id, mod_person_id, page, limit)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let added_to_community = blocking(context.pool(), move |conn| {
|
||||
ModAddCommunityView::list(conn, community_id, mod_person_id, page, limit)
|
||||
})
|
||||
.await??;
|
||||
|
||||
// These arrays are only for the full modlog, when a community isn't given
|
||||
let (removed_communities, banned, added) = if data.community_id.is_none() {
|
||||
blocking(context.pool(), move |conn| {
|
||||
Ok((
|
||||
ModRemoveCommunityView::list(conn, mod_person_id, page, limit)?,
|
||||
ModBanView::list(conn, mod_person_id, page, limit)?,
|
||||
ModAddView::list(conn, mod_person_id, page, limit)?,
|
||||
)) as Result<_, LemmyError>
|
||||
})
|
||||
.await??
|
||||
} else {
|
||||
(Vec::new(), Vec::new(), Vec::new())
|
||||
};
|
||||
|
||||
// Return the jwt
|
||||
Ok(GetModlogResponse {
|
||||
removed_posts,
|
||||
locked_posts,
|
||||
stickied_posts,
|
||||
removed_comments,
|
||||
removed_communities,
|
||||
banned_from_community,
|
||||
banned,
|
||||
added_to_community,
|
||||
added,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl Perform for CreateSite {
|
||||
type Response = SiteResponse;
|
||||
|
||||
async fn perform(
|
||||
&self,
|
||||
context: &Data<LemmyContext>,
|
||||
_websocket_id: Option<ConnectionId>,
|
||||
) -> Result<SiteResponse, LemmyError> {
|
||||
let data: &CreateSite = &self;
|
||||
|
||||
let read_site = move |conn: &'_ _| Site::read_simple(conn);
|
||||
if blocking(context.pool(), read_site).await?.is_ok() {
|
||||
return Err(ApiError::err("site_already_exists").into());
|
||||
};
|
||||
|
||||
let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
|
||||
|
||||
check_slurs(&data.name)?;
|
||||
check_slurs_opt(&data.description)?;
|
||||
|
||||
// Make sure user is an admin
|
||||
is_admin(&local_user_view)?;
|
||||
|
||||
let site_form = SiteForm {
|
||||
name: data.name.to_owned(),
|
||||
description: data.description.to_owned(),
|
||||
icon: Some(data.icon.to_owned().map(|url| url.into())),
|
||||
banner: Some(data.banner.to_owned().map(|url| url.into())),
|
||||
creator_id: local_user_view.person.id,
|
||||
enable_downvotes: data.enable_downvotes,
|
||||
open_registration: data.open_registration,
|
||||
enable_nsfw: data.enable_nsfw,
|
||||
updated: None,
|
||||
};
|
||||
|
||||
let create_site = move |conn: &'_ _| Site::create(conn, &site_form);
|
||||
if blocking(context.pool(), create_site).await?.is_err() {
|
||||
return Err(ApiError::err("site_already_exists").into());
|
||||
}
|
||||
|
||||
let site_view = blocking(context.pool(), move |conn| SiteView::read(conn)).await??;
|
||||
|
||||
Ok(SiteResponse { site_view })
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl Perform for EditSite {
|
||||
type Response = SiteResponse;
|
||||
async fn perform(
|
||||
&self,
|
||||
context: &Data<LemmyContext>,
|
||||
websocket_id: Option<ConnectionId>,
|
||||
) -> Result<SiteResponse, LemmyError> {
|
||||
let data: &EditSite = &self;
|
||||
let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
|
||||
|
||||
check_slurs(&data.name)?;
|
||||
check_slurs_opt(&data.description)?;
|
||||
|
||||
// Make sure user is an admin
|
||||
is_admin(&local_user_view)?;
|
||||
|
||||
let found_site = blocking(context.pool(), move |conn| Site::read_simple(conn)).await??;
|
||||
|
||||
let icon = diesel_option_overwrite_to_url(&data.icon)?;
|
||||
let banner = diesel_option_overwrite_to_url(&data.banner)?;
|
||||
|
||||
let site_form = SiteForm {
|
||||
name: data.name.to_owned(),
|
||||
description: data.description.to_owned(),
|
||||
icon,
|
||||
banner,
|
||||
creator_id: found_site.creator_id,
|
||||
updated: Some(naive_now()),
|
||||
enable_downvotes: data.enable_downvotes,
|
||||
open_registration: data.open_registration,
|
||||
enable_nsfw: data.enable_nsfw,
|
||||
};
|
||||
|
||||
let update_site = move |conn: &'_ _| Site::update(conn, 1, &site_form);
|
||||
if blocking(context.pool(), update_site).await?.is_err() {
|
||||
return Err(ApiError::err("couldnt_update_site").into());
|
||||
}
|
||||
|
||||
let site_view = blocking(context.pool(), move |conn| SiteView::read(conn)).await??;
|
||||
|
||||
let res = SiteResponse { site_view };
|
||||
|
||||
context.chat_server().do_send(SendAllMessage {
|
||||
op: UserOperation::EditSite,
|
||||
response: res.clone(),
|
||||
websocket_id,
|
||||
});
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl Perform for GetSite {
|
||||
type Response = GetSiteResponse;
|
||||
|
||||
async fn perform(
|
||||
&self,
|
||||
context: &Data<LemmyContext>,
|
||||
websocket_id: Option<ConnectionId>,
|
||||
) -> Result<GetSiteResponse, LemmyError> {
|
||||
let data: &GetSite = &self;
|
||||
|
||||
let site_view = match blocking(context.pool(), move |conn| SiteView::read(conn)).await? {
|
||||
Ok(site_view) => Some(site_view),
|
||||
// If the site isn't created yet, check the setup
|
||||
Err(_) => {
|
||||
if let Some(setup) = Settings::get().setup().as_ref() {
|
||||
let register = Register {
|
||||
username: setup.admin_username.to_owned(),
|
||||
email: setup.admin_email.to_owned(),
|
||||
password: setup.admin_password.to_owned(),
|
||||
password_verify: setup.admin_password.to_owned(),
|
||||
show_nsfw: true,
|
||||
captcha_uuid: None,
|
||||
captcha_answer: None,
|
||||
};
|
||||
let login_response = register.perform(context, websocket_id).await?;
|
||||
info!("Admin {} created", setup.admin_username);
|
||||
|
||||
let create_site = CreateSite {
|
||||
name: setup.site_name.to_owned(),
|
||||
description: None,
|
||||
icon: None,
|
||||
banner: None,
|
||||
enable_downvotes: true,
|
||||
open_registration: true,
|
||||
enable_nsfw: true,
|
||||
auth: login_response.jwt,
|
||||
};
|
||||
create_site.perform(context, websocket_id).await?;
|
||||
info!("Site {} created", setup.site_name);
|
||||
Some(blocking(context.pool(), move |conn| SiteView::read(conn)).await??)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mut admins = blocking(context.pool(), move |conn| PersonViewSafe::admins(conn)).await??;
|
||||
|
||||
// Make sure the site creator is the top admin
|
||||
if let Some(site_view) = site_view.to_owned() {
|
||||
let site_creator_id = site_view.creator.id;
|
||||
// TODO investigate why this is sometimes coming back null
|
||||
// Maybe user_.admin isn't being set to true?
|
||||
if let Some(creator_index) = admins.iter().position(|r| r.person.id == site_creator_id) {
|
||||
let creator_person = admins.remove(creator_index);
|
||||
admins.insert(0, creator_person);
|
||||
}
|
||||
}
|
||||
|
||||
let banned = blocking(context.pool(), move |conn| PersonViewSafe::banned(conn)).await??;
|
||||
|
||||
let online = context
|
||||
.chat_server()
|
||||
.send(GetUsersOnline)
|
||||
.await
|
||||
.unwrap_or(1);
|
||||
|
||||
let my_user = get_local_user_settings_view_from_jwt_opt(&data.auth, context.pool()).await?;
|
||||
let federated_instances = build_federated_instances(context.pool()).await?;
|
||||
|
||||
Ok(GetSiteResponse {
|
||||
site_view,
|
||||
admins,
|
||||
banned,
|
||||
online,
|
||||
version: version::VERSION.to_string(),
|
||||
my_user,
|
||||
federated_instances,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl Perform for Search {
|
||||
type Response = SearchResponse;
|
||||
|
||||
async fn perform(
|
||||
&self,
|
||||
context: &Data<LemmyContext>,
|
||||
_websocket_id: Option<ConnectionId>,
|
||||
) -> Result<SearchResponse, LemmyError> {
|
||||
let data: &Search = &self;
|
||||
|
||||
match search_by_apub_id(&data.q, context).await {
|
||||
Ok(r) => return Ok(r),
|
||||
Err(e) => debug!("Failed to resolve search query as activitypub ID: {}", e),
|
||||
}
|
||||
|
||||
let local_user_view = get_local_user_view_from_jwt_opt(&data.auth, context.pool()).await?;
|
||||
let person_id = local_user_view.map(|u| u.person.id);
|
||||
|
||||
let type_ = SearchType::from_str(&data.type_)?;
|
||||
|
||||
let mut posts = Vec::new();
|
||||
let mut comments = Vec::new();
|
||||
let mut communities = Vec::new();
|
||||
let mut users = Vec::new();
|
||||
|
||||
// TODO no clean / non-nsfw searching rn
|
||||
|
||||
let q = data.q.to_owned();
|
||||
let page = data.page;
|
||||
let limit = data.limit;
|
||||
let sort = SortType::from_str(&data.sort)?;
|
||||
let community_id = data.community_id;
|
||||
let community_name = data.community_name.to_owned();
|
||||
match type_ {
|
||||
SearchType::Posts => {
|
||||
posts = blocking(context.pool(), move |conn| {
|
||||
PostQueryBuilder::create(conn)
|
||||
.sort(&sort)
|
||||
.show_nsfw(true)
|
||||
.community_id(community_id)
|
||||
.community_name(community_name)
|
||||
.my_person_id(person_id)
|
||||
.search_term(q)
|
||||
.page(page)
|
||||
.limit(limit)
|
||||
.list()
|
||||
})
|
||||
.await??;
|
||||
}
|
||||
SearchType::Comments => {
|
||||
comments = blocking(context.pool(), move |conn| {
|
||||
CommentQueryBuilder::create(&conn)
|
||||
.sort(&sort)
|
||||
.search_term(q)
|
||||
.my_person_id(person_id)
|
||||
.page(page)
|
||||
.limit(limit)
|
||||
.list()
|
||||
})
|
||||
.await??;
|
||||
}
|
||||
SearchType::Communities => {
|
||||
communities = blocking(context.pool(), move |conn| {
|
||||
CommunityQueryBuilder::create(conn)
|
||||
.sort(&sort)
|
||||
.search_term(q)
|
||||
.my_person_id(person_id)
|
||||
.page(page)
|
||||
.limit(limit)
|
||||
.list()
|
||||
})
|
||||
.await??;
|
||||
}
|
||||
SearchType::Users => {
|
||||
users = blocking(context.pool(), move |conn| {
|
||||
PersonQueryBuilder::create(conn)
|
||||
.sort(&sort)
|
||||
.search_term(q)
|
||||
.page(page)
|
||||
.limit(limit)
|
||||
.list()
|
||||
})
|
||||
.await??;
|
||||
}
|
||||
SearchType::All => {
|
||||
posts = blocking(context.pool(), move |conn| {
|
||||
PostQueryBuilder::create(conn)
|
||||
.sort(&sort)
|
||||
.show_nsfw(true)
|
||||
.community_id(community_id)
|
||||
.community_name(community_name)
|
||||
.my_person_id(person_id)
|
||||
.search_term(q)
|
||||
.page(page)
|
||||
.limit(limit)
|
||||
.list()
|
||||
})
|
||||
.await??;
|
||||
|
||||
let q = data.q.to_owned();
|
||||
let sort = SortType::from_str(&data.sort)?;
|
||||
|
||||
comments = blocking(context.pool(), move |conn| {
|
||||
CommentQueryBuilder::create(conn)
|
||||
.sort(&sort)
|
||||
.search_term(q)
|
||||
.my_person_id(person_id)
|
||||
.page(page)
|
||||
.limit(limit)
|
||||
.list()
|
||||
})
|
||||
.await??;
|
||||
|
||||
let q = data.q.to_owned();
|
||||
let sort = SortType::from_str(&data.sort)?;
|
||||
|
||||
communities = blocking(context.pool(), move |conn| {
|
||||
CommunityQueryBuilder::create(conn)
|
||||
.sort(&sort)
|
||||
.search_term(q)
|
||||
.my_person_id(person_id)
|
||||
.page(page)
|
||||
.limit(limit)
|
||||
.list()
|
||||
})
|
||||
.await??;
|
||||
|
||||
let q = data.q.to_owned();
|
||||
let sort = SortType::from_str(&data.sort)?;
|
||||
|
||||
users = blocking(context.pool(), move |conn| {
|
||||
PersonQueryBuilder::create(conn)
|
||||
.sort(&sort)
|
||||
.search_term(q)
|
||||
.page(page)
|
||||
.limit(limit)
|
||||
.list()
|
||||
})
|
||||
.await??;
|
||||
}
|
||||
SearchType::Url => {
|
||||
posts = blocking(context.pool(), move |conn| {
|
||||
PostQueryBuilder::create(conn)
|
||||
.sort(&sort)
|
||||
.show_nsfw(true)
|
||||
.my_person_id(person_id)
|
||||
.community_id(community_id)
|
||||
.community_name(community_name)
|
||||
.url_search(q)
|
||||
.page(page)
|
||||
.limit(limit)
|
||||
.list()
|
||||
})
|
||||
.await??;
|
||||
}
|
||||
};
|
||||
|
||||
// Return the jwt
|
||||
Ok(SearchResponse {
|
||||
type_: data.type_.to_owned(),
|
||||
comments,
|
||||
posts,
|
||||
communities,
|
||||
users,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl Perform for TransferSite {
|
||||
type Response = GetSiteResponse;
|
||||
|
||||
async fn perform(
|
||||
&self,
|
||||
context: &Data<LemmyContext>,
|
||||
_websocket_id: Option<ConnectionId>,
|
||||
) -> Result<GetSiteResponse, LemmyError> {
|
||||
let data: &TransferSite = &self;
|
||||
let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
|
||||
|
||||
is_admin(&local_user_view)?;
|
||||
|
||||
let read_site = blocking(context.pool(), move |conn| Site::read_simple(conn)).await??;
|
||||
|
||||
// Make sure user is the creator
|
||||
if read_site.creator_id != local_user_view.person.id {
|
||||
return Err(ApiError::err("not_an_admin").into());
|
||||
}
|
||||
|
||||
let new_creator_id = data.person_id;
|
||||
let transfer_site = move |conn: &'_ _| Site::transfer(conn, new_creator_id);
|
||||
if blocking(context.pool(), transfer_site).await?.is_err() {
|
||||
return Err(ApiError::err("couldnt_update_site").into());
|
||||
};
|
||||
|
||||
// Mod tables
|
||||
let form = ModAddForm {
|
||||
mod_person_id: local_user_view.person.id,
|
||||
other_person_id: data.person_id,
|
||||
removed: Some(false),
|
||||
};
|
||||
|
||||
blocking(context.pool(), move |conn| ModAdd::create(conn, &form)).await??;
|
||||
|
||||
let site_view = blocking(context.pool(), move |conn| SiteView::read(conn)).await??;
|
||||
|
||||
let mut admins = blocking(context.pool(), move |conn| PersonViewSafe::admins(conn)).await??;
|
||||
let creator_index = admins
|
||||
.iter()
|
||||
.position(|r| r.person.id == site_view.creator.id)
|
||||
.context(location_info!())?;
|
||||
let creator_person = admins.remove(creator_index);
|
||||
admins.insert(0, creator_person);
|
||||
|
||||
let banned = blocking(context.pool(), move |conn| PersonViewSafe::banned(conn)).await??;
|
||||
let federated_instances = build_federated_instances(context.pool()).await?;
|
||||
|
||||
let my_user = Some(get_local_user_settings_view_from_jwt(&data.auth, context.pool()).await?);
|
||||
|
||||
Ok(GetSiteResponse {
|
||||
site_view: Some(site_view),
|
||||
admins,
|
||||
banned,
|
||||
online: 0,
|
||||
version: version::VERSION.to_string(),
|
||||
my_user,
|
||||
federated_instances,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl Perform for GetSiteConfig {
|
||||
type Response = GetSiteConfigResponse;
|
||||
|
||||
async fn perform(
|
||||
&self,
|
||||
context: &Data<LemmyContext>,
|
||||
_websocket_id: Option<ConnectionId>,
|
||||
) -> Result<GetSiteConfigResponse, LemmyError> {
|
||||
let data: &GetSiteConfig = &self;
|
||||
let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
|
||||
|
||||
// Only let admins read this
|
||||
is_admin(&local_user_view)?;
|
||||
|
||||
let config_hjson = Settings::read_config_file()?;
|
||||
|
||||
Ok(GetSiteConfigResponse { config_hjson })
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl Perform for SaveSiteConfig {
|
||||
type Response = GetSiteConfigResponse;
|
||||
|
||||
async fn perform(
|
||||
&self,
|
||||
context: &Data<LemmyContext>,
|
||||
_websocket_id: Option<ConnectionId>,
|
||||
) -> Result<GetSiteConfigResponse, LemmyError> {
|
||||
let data: &SaveSiteConfig = &self;
|
||||
let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
|
||||
|
||||
// Only let admins read this
|
||||
is_admin(&local_user_view)?;
|
||||
|
||||
// Make sure docker doesn't have :ro at the end of the volume, so its not a read-only filesystem
|
||||
let config_hjson = match Settings::save_config_file(&data.config_hjson) {
|
||||
Ok(config_hjson) => config_hjson,
|
||||
Err(_e) => return Err(ApiError::err("couldnt_update_site").into()),
|
||||
};
|
||||
|
||||
Ok(GetSiteConfigResponse { config_hjson })
|
||||
}
|
||||
}
|
97
crates/api/src/websocket.rs
Normal file
97
crates/api/src/websocket.rs
Normal file
|
@ -0,0 +1,97 @@
|
|||
use crate::{get_local_user_view_from_jwt, Perform};
|
||||
use actix_web::web::Data;
|
||||
use lemmy_api_structs::websocket::*;
|
||||
use lemmy_utils::{ConnectionId, LemmyError};
|
||||
use lemmy_websocket::{
|
||||
messages::{JoinCommunityRoom, JoinModRoom, JoinPostRoom, JoinUserRoom},
|
||||
LemmyContext,
|
||||
};
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl Perform for UserJoin {
|
||||
type Response = UserJoinResponse;
|
||||
|
||||
async fn perform(
|
||||
&self,
|
||||
context: &Data<LemmyContext>,
|
||||
websocket_id: Option<ConnectionId>,
|
||||
) -> Result<UserJoinResponse, LemmyError> {
|
||||
let data: &UserJoin = &self;
|
||||
let local_user_view = get_local_user_view_from_jwt(&data.auth, context.pool()).await?;
|
||||
|
||||
if let Some(ws_id) = websocket_id {
|
||||
context.chat_server().do_send(JoinUserRoom {
|
||||
local_user_id: local_user_view.local_user.id,
|
||||
id: ws_id,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(UserJoinResponse { joined: true })
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl Perform for CommunityJoin {
|
||||
type Response = CommunityJoinResponse;
|
||||
|
||||
async fn perform(
|
||||
&self,
|
||||
context: &Data<LemmyContext>,
|
||||
websocket_id: Option<ConnectionId>,
|
||||
) -> Result<CommunityJoinResponse, LemmyError> {
|
||||
let data: &CommunityJoin = &self;
|
||||
|
||||
if let Some(ws_id) = websocket_id {
|
||||
context.chat_server().do_send(JoinCommunityRoom {
|
||||
community_id: data.community_id,
|
||||
id: ws_id,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(CommunityJoinResponse { joined: true })
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl Perform for ModJoin {
|
||||
type Response = ModJoinResponse;
|
||||
|
||||
async fn perform(
|
||||
&self,
|
||||
context: &Data<LemmyContext>,
|
||||
websocket_id: Option<ConnectionId>,
|
||||
) -> Result<ModJoinResponse, LemmyError> {
|
||||
let data: &ModJoin = &self;
|
||||
|
||||
if let Some(ws_id) = websocket_id {
|
||||
context.chat_server().do_send(JoinModRoom {
|
||||
community_id: data.community_id,
|
||||
id: ws_id,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ModJoinResponse { joined: true })
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl Perform for PostJoin {
|
||||
type Response = PostJoinResponse;
|
||||
|
||||
async fn perform(
|
||||
&self,
|
||||
context: &Data<LemmyContext>,
|
||||
websocket_id: Option<ConnectionId>,
|
||||
) -> Result<PostJoinResponse, LemmyError> {
|
||||
let data: &PostJoin = &self;
|
||||
|
||||
if let Some(ws_id) = websocket_id {
|
||||
context.chat_server().do_send(JoinPostRoom {
|
||||
post_id: data.post_id,
|
||||
id: ws_id,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(PostJoinResponse { joined: true })
|
||||
}
|
||||
}
|
24
crates/api_structs/Cargo.toml
Normal file
24
crates/api_structs/Cargo.toml
Normal file
|
@ -0,0 +1,24 @@
|
|||
[package]
|
||||
name = "lemmy_api_structs"
|
||||
version = "0.1.0"
|
||||
edition = "2018"
|
||||
|
||||
[lib]
|
||||
name = "lemmy_api_structs"
|
||||
path = "src/lib.rs"
|
||||
doctest = false
|
||||
|
||||
[dependencies]
|
||||
lemmy_db_queries = { path = "../db_queries" }
|
||||
lemmy_db_views = { path = "../db_views" }
|
||||
lemmy_db_views_moderator = { path = "../db_views_moderator" }
|
||||
lemmy_db_views_actor = { path = "../db_views_actor" }
|
||||
lemmy_db_schema = { path = "../db_schema" }
|
||||
lemmy_utils = { path = "../utils" }
|
||||
serde = { version = "1.0.123", features = ["derive"] }
|
||||
log = "0.4.14"
|
||||
diesel = "1.4.5"
|
||||
actix-web = "3.3.2"
|
||||
chrono = { version = "0.4.19", features = ["serde"] }
|
||||
serde_json = { version = "1.0.61", features = ["preserve_order"] }
|
||||
url = "2.2.1"
|
119
crates/api_structs/src/comment.rs
Normal file
119
crates/api_structs/src/comment.rs
Normal file
|
@ -0,0 +1,119 @@
|
|||
use lemmy_db_schema::{CommentId, CommunityId, LocalUserId, PostId};
|
||||
use lemmy_db_views::{comment_report_view::CommentReportView, comment_view::CommentView};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateComment {
|
||||
pub content: String,
|
||||
pub parent_id: Option<CommentId>,
|
||||
pub post_id: PostId,
|
||||
pub form_id: Option<String>,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct EditComment {
|
||||
pub content: String,
|
||||
pub comment_id: CommentId,
|
||||
pub form_id: Option<String>,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DeleteComment {
|
||||
pub comment_id: CommentId,
|
||||
pub deleted: bool,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RemoveComment {
|
||||
pub comment_id: CommentId,
|
||||
pub removed: bool,
|
||||
pub reason: Option<String>,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct MarkCommentAsRead {
|
||||
pub comment_id: CommentId,
|
||||
pub read: bool,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SaveComment {
|
||||
pub comment_id: CommentId,
|
||||
pub save: bool,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct CommentResponse {
|
||||
pub comment_view: CommentView,
|
||||
pub recipient_ids: Vec<LocalUserId>,
|
||||
pub form_id: Option<String>, // An optional front end ID, to tell which is coming back
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateCommentLike {
|
||||
pub comment_id: CommentId,
|
||||
pub score: i16,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GetComments {
|
||||
pub type_: String,
|
||||
pub sort: String,
|
||||
pub page: Option<i64>,
|
||||
pub limit: Option<i64>,
|
||||
pub community_id: Option<CommunityId>,
|
||||
pub community_name: Option<String>,
|
||||
pub auth: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct GetCommentsResponse {
|
||||
pub comments: Vec<CommentView>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct CreateCommentReport {
|
||||
pub comment_id: CommentId,
|
||||
pub reason: String,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct CreateCommentReportResponse {
|
||||
pub success: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct ResolveCommentReport {
|
||||
pub report_id: i32,
|
||||
pub resolved: bool,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct ResolveCommentReportResponse {
|
||||
// TODO this should probably return the view
|
||||
pub report_id: i32,
|
||||
pub resolved: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct ListCommentReports {
|
||||
pub page: Option<i64>,
|
||||
pub limit: Option<i64>,
|
||||
/// if no community is given, it returns reports for all communities moderated by the auth user
|
||||
pub community: Option<CommunityId>,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone, Debug)]
|
||||
pub struct ListCommentReportsResponse {
|
||||
pub comments: Vec<CommentReportView>,
|
||||
}
|
133
crates/api_structs/src/community.rs
Normal file
133
crates/api_structs/src/community.rs
Normal file
|
@ -0,0 +1,133 @@
|
|||
use lemmy_db_schema::{CommunityId, PersonId};
|
||||
use lemmy_db_views_actor::{
|
||||
community_follower_view::CommunityFollowerView,
|
||||
community_moderator_view::CommunityModeratorView,
|
||||
community_view::CommunityView,
|
||||
person_view::PersonViewSafe,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GetCommunity {
|
||||
pub id: Option<CommunityId>,
|
||||
pub name: Option<String>,
|
||||
pub auth: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct GetCommunityResponse {
|
||||
pub community_view: CommunityView,
|
||||
pub moderators: Vec<CommunityModeratorView>,
|
||||
pub online: usize,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateCommunity {
|
||||
pub name: String,
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
pub banner: Option<String>,
|
||||
pub nsfw: bool,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct CommunityResponse {
|
||||
pub community_view: CommunityView,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct ListCommunities {
|
||||
pub type_: String,
|
||||
pub sort: String,
|
||||
pub page: Option<i64>,
|
||||
pub limit: Option<i64>,
|
||||
pub auth: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct ListCommunitiesResponse {
|
||||
pub communities: Vec<CommunityView>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
pub struct BanFromCommunity {
|
||||
pub community_id: CommunityId,
|
||||
pub person_id: PersonId,
|
||||
pub ban: bool,
|
||||
pub remove_data: bool,
|
||||
pub reason: Option<String>,
|
||||
pub expires: Option<i64>,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct BanFromCommunityResponse {
|
||||
pub person_view: PersonViewSafe,
|
||||
pub banned: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AddModToCommunity {
|
||||
pub community_id: CommunityId,
|
||||
pub person_id: PersonId,
|
||||
pub added: bool,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct AddModToCommunityResponse {
|
||||
pub moderators: Vec<CommunityModeratorView>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct EditCommunity {
|
||||
pub community_id: CommunityId,
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
pub banner: Option<String>,
|
||||
pub nsfw: bool,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DeleteCommunity {
|
||||
pub community_id: CommunityId,
|
||||
pub deleted: bool,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RemoveCommunity {
|
||||
pub community_id: CommunityId,
|
||||
pub removed: bool,
|
||||
pub reason: Option<String>,
|
||||
pub expires: Option<i64>,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct FollowCommunity {
|
||||
pub community_id: CommunityId,
|
||||
pub follow: bool,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GetFollowedCommunities {
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct GetFollowedCommunitiesResponse {
|
||||
pub communities: Vec<CommunityFollowerView>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TransferCommunity {
|
||||
pub community_id: CommunityId,
|
||||
pub person_id: PersonId,
|
||||
pub auth: String,
|
||||
}
|
191
crates/api_structs/src/lib.rs
Normal file
191
crates/api_structs/src/lib.rs
Normal file
|
@ -0,0 +1,191 @@
|
|||
pub mod comment;
|
||||
pub mod community;
|
||||
pub mod person;
|
||||
pub mod post;
|
||||
pub mod site;
|
||||
pub mod websocket;
|
||||
|
||||
use diesel::PgConnection;
|
||||
use lemmy_db_queries::{Crud, DbPool};
|
||||
use lemmy_db_schema::{
|
||||
source::{
|
||||
comment::Comment,
|
||||
person::Person,
|
||||
person_mention::{PersonMention, PersonMentionForm},
|
||||
post::Post,
|
||||
},
|
||||
LocalUserId,
|
||||
};
|
||||
use lemmy_db_views::local_user_view::LocalUserView;
|
||||
use lemmy_utils::{email::send_email, settings::structs::Settings, utils::MentionData, LemmyError};
|
||||
use log::error;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct WebFingerLink {
|
||||
pub rel: Option<String>,
|
||||
#[serde(rename(serialize = "type", deserialize = "type"))]
|
||||
pub type_: Option<String>,
|
||||
pub href: Option<Url>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub template: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct WebFingerResponse {
|
||||
pub subject: String,
|
||||
pub aliases: Vec<Url>,
|
||||
pub links: Vec<WebFingerLink>,
|
||||
}
|
||||
|
||||
pub async fn blocking<F, T>(pool: &DbPool, f: F) -> Result<T, LemmyError>
|
||||
where
|
||||
F: FnOnce(&diesel::PgConnection) -> T + Send + 'static,
|
||||
T: Send + 'static,
|
||||
{
|
||||
let pool = pool.clone();
|
||||
let res = actix_web::web::block(move || {
|
||||
let conn = pool.get()?;
|
||||
let res = (f)(&conn);
|
||||
Ok(res) as Result<_, LemmyError>
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
pub async fn send_local_notifs(
|
||||
mentions: Vec<MentionData>,
|
||||
comment: Comment,
|
||||
person: Person,
|
||||
post: Post,
|
||||
pool: &DbPool,
|
||||
do_send_email: bool,
|
||||
) -> Result<Vec<LocalUserId>, LemmyError> {
|
||||
let ids = blocking(pool, move |conn| {
|
||||
do_send_local_notifs(conn, &mentions, &comment, &person, &post, do_send_email)
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
fn do_send_local_notifs(
|
||||
conn: &PgConnection,
|
||||
mentions: &[MentionData],
|
||||
comment: &Comment,
|
||||
person: &Person,
|
||||
post: &Post,
|
||||
do_send_email: bool,
|
||||
) -> Vec<LocalUserId> {
|
||||
let mut recipient_ids = Vec::new();
|
||||
|
||||
// Send the local mentions
|
||||
for mention in mentions
|
||||
.iter()
|
||||
.filter(|m| m.is_local() && m.name.ne(&person.name))
|
||||
.collect::<Vec<&MentionData>>()
|
||||
{
|
||||
if let Ok(mention_user_view) = LocalUserView::read_from_name(&conn, &mention.name) {
|
||||
// TODO
|
||||
// At some point, make it so you can't tag the parent creator either
|
||||
// This can cause two notifications, one for reply and the other for mention
|
||||
recipient_ids.push(mention_user_view.local_user.id);
|
||||
|
||||
let user_mention_form = PersonMentionForm {
|
||||
recipient_id: mention_user_view.person.id,
|
||||
comment_id: comment.id,
|
||||
read: None,
|
||||
};
|
||||
|
||||
// Allow this to fail softly, since comment edits might re-update or replace it
|
||||
// Let the uniqueness handle this fail
|
||||
PersonMention::create(&conn, &user_mention_form).ok();
|
||||
|
||||
// Send an email to those local users that have notifications on
|
||||
if do_send_email {
|
||||
send_email_to_user(
|
||||
&mention_user_view,
|
||||
"Mentioned by",
|
||||
"Person Mention",
|
||||
&comment.content,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send notifs to the parent commenter / poster
|
||||
match comment.parent_id {
|
||||
Some(parent_id) => {
|
||||
if let Ok(parent_comment) = Comment::read(&conn, parent_id) {
|
||||
// Don't send a notif to yourself
|
||||
if parent_comment.creator_id != person.id {
|
||||
// Get the parent commenter local_user
|
||||
if let Ok(parent_user_view) = LocalUserView::read_person(&conn, parent_comment.creator_id)
|
||||
{
|
||||
recipient_ids.push(parent_user_view.local_user.id);
|
||||
|
||||
if do_send_email {
|
||||
send_email_to_user(
|
||||
&parent_user_view,
|
||||
"Reply from",
|
||||
"Comment Reply",
|
||||
&comment.content,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Its a post
|
||||
None => {
|
||||
if post.creator_id != person.id {
|
||||
if let Ok(parent_user_view) = LocalUserView::read_person(&conn, post.creator_id) {
|
||||
recipient_ids.push(parent_user_view.local_user.id);
|
||||
|
||||
if do_send_email {
|
||||
send_email_to_user(
|
||||
&parent_user_view,
|
||||
"Reply from",
|
||||
"Post Reply",
|
||||
&comment.content,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
recipient_ids
|
||||
}
|
||||
|
||||
pub fn send_email_to_user(
|
||||
local_user_view: &LocalUserView,
|
||||
subject_text: &str,
|
||||
body_text: &str,
|
||||
comment_content: &str,
|
||||
) {
|
||||
if local_user_view.person.banned || !local_user_view.local_user.send_notifications_to_email {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(user_email) = &local_user_view.local_user.email {
|
||||
let subject = &format!(
|
||||
"{} - {} {}",
|
||||
subject_text,
|
||||
Settings::get().hostname(),
|
||||
local_user_view.person.name,
|
||||
);
|
||||
let html = &format!(
|
||||
"<h1>{}</h1><br><div>{} - {}</div><br><a href={}/inbox>inbox</a>",
|
||||
body_text,
|
||||
local_user_view.person.name,
|
||||
comment_content,
|
||||
Settings::get().get_protocol_and_hostname()
|
||||
);
|
||||
match send_email(subject, &user_email, &local_user_view.person.name, html) {
|
||||
Ok(_o) => _o,
|
||||
Err(e) => error!("{}", e),
|
||||
};
|
||||
}
|
||||
}
|
245
crates/api_structs/src/person.rs
Normal file
245
crates/api_structs/src/person.rs
Normal file
|
@ -0,0 +1,245 @@
|
|||
use lemmy_db_views::{
|
||||
comment_view::CommentView,
|
||||
post_view::PostView,
|
||||
private_message_view::PrivateMessageView,
|
||||
};
|
||||
use lemmy_db_views_actor::{
|
||||
community_follower_view::CommunityFollowerView,
|
||||
community_moderator_view::CommunityModeratorView,
|
||||
person_mention_view::PersonMentionView,
|
||||
person_view::PersonViewSafe,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct Login {
|
||||
pub username_or_email: String,
|
||||
pub password: String,
|
||||
}
|
||||
use lemmy_db_schema::{CommunityId, PersonId, PersonMentionId, PrivateMessageId};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Register {
|
||||
pub username: String,
|
||||
pub email: Option<String>,
|
||||
pub password: String,
|
||||
pub password_verify: String,
|
||||
pub show_nsfw: bool,
|
||||
pub captcha_uuid: Option<String>,
|
||||
pub captcha_answer: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GetCaptcha {}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct GetCaptchaResponse {
|
||||
pub ok: Option<CaptchaResponse>, // Will be None if captchas are disabled
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CaptchaResponse {
|
||||
pub png: String, // A Base64 encoded png
|
||||
pub wav: Option<String>, // A Base64 encoded wav audio
|
||||
pub uuid: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SaveUserSettings {
|
||||
pub show_nsfw: Option<bool>,
|
||||
pub theme: Option<String>,
|
||||
pub default_sort_type: Option<i16>,
|
||||
pub default_listing_type: Option<i16>,
|
||||
pub lang: Option<String>,
|
||||
pub avatar: Option<String>,
|
||||
pub banner: Option<String>,
|
||||
pub preferred_username: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub bio: Option<String>,
|
||||
pub matrix_user_id: Option<String>,
|
||||
pub new_password: Option<String>,
|
||||
pub new_password_verify: Option<String>,
|
||||
pub old_password: Option<String>,
|
||||
pub show_avatars: Option<bool>,
|
||||
pub send_notifications_to_email: Option<bool>,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct LoginResponse {
|
||||
pub jwt: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GetPersonDetails {
|
||||
pub person_id: Option<PersonId>,
|
||||
pub username: Option<String>,
|
||||
pub sort: String,
|
||||
pub page: Option<i64>,
|
||||
pub limit: Option<i64>,
|
||||
pub community_id: Option<CommunityId>,
|
||||
pub saved_only: bool,
|
||||
pub auth: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct GetPersonDetailsResponse {
|
||||
pub person_view: PersonViewSafe,
|
||||
pub follows: Vec<CommunityFollowerView>,
|
||||
pub moderates: Vec<CommunityModeratorView>,
|
||||
pub comments: Vec<CommentView>,
|
||||
pub posts: Vec<PostView>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct GetRepliesResponse {
|
||||
pub replies: Vec<CommentView>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct GetPersonMentionsResponse {
|
||||
pub mentions: Vec<PersonMentionView>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct MarkAllAsRead {
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AddAdmin {
|
||||
pub person_id: PersonId,
|
||||
pub added: bool,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct AddAdminResponse {
|
||||
pub admins: Vec<PersonViewSafe>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct BanPerson {
|
||||
pub person_id: PersonId,
|
||||
pub ban: bool,
|
||||
pub remove_data: bool,
|
||||
pub reason: Option<String>,
|
||||
pub expires: Option<i64>,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct BanPersonResponse {
|
||||
pub person_view: PersonViewSafe,
|
||||
pub banned: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GetReplies {
|
||||
pub sort: String,
|
||||
pub page: Option<i64>,
|
||||
pub limit: Option<i64>,
|
||||
pub unread_only: bool,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GetPersonMentions {
|
||||
pub sort: String,
|
||||
pub page: Option<i64>,
|
||||
pub limit: Option<i64>,
|
||||
pub unread_only: bool,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct MarkPersonMentionAsRead {
|
||||
pub person_mention_id: PersonMentionId,
|
||||
pub read: bool,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct PersonMentionResponse {
|
||||
pub person_mention_view: PersonMentionView,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DeleteAccount {
|
||||
pub password: String,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PasswordReset {
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct PasswordResetResponse {}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PasswordChange {
|
||||
pub token: String,
|
||||
pub password: String,
|
||||
pub password_verify: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreatePrivateMessage {
|
||||
pub content: String,
|
||||
pub recipient_id: PersonId,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct EditPrivateMessage {
|
||||
pub private_message_id: PrivateMessageId,
|
||||
pub content: String,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DeletePrivateMessage {
|
||||
pub private_message_id: PrivateMessageId,
|
||||
pub deleted: bool,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct MarkPrivateMessageAsRead {
|
||||
pub private_message_id: PrivateMessageId,
|
||||
pub read: bool,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GetPrivateMessages {
|
||||
pub unread_only: bool,
|
||||
pub page: Option<i64>,
|
||||
pub limit: Option<i64>,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct PrivateMessagesResponse {
|
||||
pub private_messages: Vec<PrivateMessageView>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct PrivateMessageResponse {
|
||||
pub private_message_view: PrivateMessageView,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct GetReportCount {
|
||||
pub community: Option<CommunityId>,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct GetReportCountResponse {
|
||||
pub community: Option<CommunityId>,
|
||||
pub comment_reports: i64,
|
||||
pub post_reports: i64,
|
||||
}
|
149
crates/api_structs/src/post.rs
Normal file
149
crates/api_structs/src/post.rs
Normal file
|
@ -0,0 +1,149 @@
|
|||
use lemmy_db_schema::{CommunityId, PostId};
|
||||
use lemmy_db_views::{
|
||||
comment_view::CommentView,
|
||||
post_report_view::PostReportView,
|
||||
post_view::PostView,
|
||||
};
|
||||
use lemmy_db_views_actor::{
|
||||
community_moderator_view::CommunityModeratorView,
|
||||
community_view::CommunityView,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct CreatePost {
|
||||
pub name: String,
|
||||
pub url: Option<Url>,
|
||||
pub body: Option<String>,
|
||||
pub nsfw: bool,
|
||||
pub community_id: CommunityId,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct PostResponse {
|
||||
pub post_view: PostView,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GetPost {
|
||||
pub id: PostId,
|
||||
pub auth: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct GetPostResponse {
|
||||
pub post_view: PostView,
|
||||
pub community_view: CommunityView,
|
||||
pub comments: Vec<CommentView>,
|
||||
pub moderators: Vec<CommunityModeratorView>,
|
||||
pub online: usize,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct GetPosts {
|
||||
pub type_: String,
|
||||
pub sort: String,
|
||||
pub page: Option<i64>,
|
||||
pub limit: Option<i64>,
|
||||
pub community_id: Option<CommunityId>,
|
||||
pub community_name: Option<String>,
|
||||
pub auth: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct GetPostsResponse {
|
||||
pub posts: Vec<PostView>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreatePostLike {
|
||||
pub post_id: PostId,
|
||||
pub score: i16,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct EditPost {
|
||||
pub post_id: PostId,
|
||||
pub name: String,
|
||||
pub url: Option<Url>,
|
||||
pub body: Option<String>,
|
||||
pub nsfw: bool,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DeletePost {
|
||||
pub post_id: PostId,
|
||||
pub deleted: bool,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RemovePost {
|
||||
pub post_id: PostId,
|
||||
pub removed: bool,
|
||||
pub reason: Option<String>,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LockPost {
|
||||
pub post_id: PostId,
|
||||
pub locked: bool,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct StickyPost {
|
||||
pub post_id: PostId,
|
||||
pub stickied: bool,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SavePost {
|
||||
pub post_id: PostId,
|
||||
pub save: bool,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct CreatePostReport {
|
||||
pub post_id: PostId,
|
||||
pub reason: String,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct CreatePostReportResponse {
|
||||
pub success: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct ResolvePostReport {
|
||||
pub report_id: i32,
|
||||
pub resolved: bool,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct ResolvePostReportResponse {
|
||||
pub report_id: i32,
|
||||
pub resolved: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct ListPostReports {
|
||||
pub page: Option<i64>,
|
||||
pub limit: Option<i64>,
|
||||
pub community: Option<CommunityId>,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone, Debug)]
|
||||
pub struct ListPostReportsResponse {
|
||||
pub posts: Vec<PostReportView>,
|
||||
}
|
137
crates/api_structs/src/site.rs
Normal file
137
crates/api_structs/src/site.rs
Normal file
|
@ -0,0 +1,137 @@
|
|||
use lemmy_db_schema::{CommunityId, PersonId};
|
||||
use lemmy_db_views::{
|
||||
comment_view::CommentView,
|
||||
local_user_view::LocalUserSettingsView,
|
||||
post_view::PostView,
|
||||
site_view::SiteView,
|
||||
};
|
||||
use lemmy_db_views_actor::{community_view::CommunityView, person_view::PersonViewSafe};
|
||||
use lemmy_db_views_moderator::{
|
||||
mod_add_community_view::ModAddCommunityView,
|
||||
mod_add_view::ModAddView,
|
||||
mod_ban_from_community_view::ModBanFromCommunityView,
|
||||
mod_ban_view::ModBanView,
|
||||
mod_lock_post_view::ModLockPostView,
|
||||
mod_remove_comment_view::ModRemoveCommentView,
|
||||
mod_remove_community_view::ModRemoveCommunityView,
|
||||
mod_remove_post_view::ModRemovePostView,
|
||||
mod_sticky_post_view::ModStickyPostView,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct Search {
|
||||
pub q: String,
|
||||
pub type_: String,
|
||||
pub community_id: Option<CommunityId>,
|
||||
pub community_name: Option<String>,
|
||||
pub sort: String,
|
||||
pub page: Option<i64>,
|
||||
pub limit: Option<i64>,
|
||||
pub auth: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct SearchResponse {
|
||||
pub type_: String,
|
||||
pub comments: Vec<CommentView>,
|
||||
pub posts: Vec<PostView>,
|
||||
pub communities: Vec<CommunityView>,
|
||||
pub users: Vec<PersonViewSafe>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GetModlog {
|
||||
pub mod_person_id: Option<PersonId>,
|
||||
pub community_id: Option<CommunityId>,
|
||||
pub page: Option<i64>,
|
||||
pub limit: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct GetModlogResponse {
|
||||
pub removed_posts: Vec<ModRemovePostView>,
|
||||
pub locked_posts: Vec<ModLockPostView>,
|
||||
pub stickied_posts: Vec<ModStickyPostView>,
|
||||
pub removed_comments: Vec<ModRemoveCommentView>,
|
||||
pub removed_communities: Vec<ModRemoveCommunityView>,
|
||||
pub banned_from_community: Vec<ModBanFromCommunityView>,
|
||||
pub banned: Vec<ModBanView>,
|
||||
pub added_to_community: Vec<ModAddCommunityView>,
|
||||
pub added: Vec<ModAddView>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateSite {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub icon: Option<Url>,
|
||||
pub banner: Option<Url>,
|
||||
pub enable_downvotes: bool,
|
||||
pub open_registration: bool,
|
||||
pub enable_nsfw: bool,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct EditSite {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
pub banner: Option<String>,
|
||||
pub enable_downvotes: bool,
|
||||
pub open_registration: bool,
|
||||
pub enable_nsfw: bool,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GetSite {
|
||||
pub auth: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct SiteResponse {
|
||||
pub site_view: SiteView,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct GetSiteResponse {
|
||||
pub site_view: Option<SiteView>, // Because the site might not be set up yet
|
||||
pub admins: Vec<PersonViewSafe>,
|
||||
pub banned: Vec<PersonViewSafe>,
|
||||
pub online: usize,
|
||||
pub version: String,
|
||||
pub my_user: Option<LocalUserSettingsView>,
|
||||
pub federated_instances: Option<FederatedInstances>, // Federation may be disabled
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TransferSite {
|
||||
pub person_id: PersonId,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GetSiteConfig {
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct GetSiteConfigResponse {
|
||||
pub config_hjson: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SaveSiteConfig {
|
||||
pub config_hjson: String,
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct FederatedInstances {
|
||||
pub linked: Vec<String>,
|
||||
pub allowed: Option<Vec<String>>,
|
||||
pub blocked: Option<Vec<String>>,
|
||||
}
|
42
crates/api_structs/src/websocket.rs
Normal file
42
crates/api_structs/src/websocket.rs
Normal file
|
@ -0,0 +1,42 @@
|
|||
use lemmy_db_schema::{CommunityId, PostId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct UserJoin {
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct UserJoinResponse {
|
||||
pub joined: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct CommunityJoin {
|
||||
pub community_id: CommunityId,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct CommunityJoinResponse {
|
||||
pub joined: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct ModJoin {
|
||||
pub community_id: CommunityId,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct ModJoinResponse {
|
||||
pub joined: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct PostJoin {
|
||||
pub post_id: PostId,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct PostJoinResponse {
|
||||
pub joined: bool,
|
||||
}
|
52
crates/apub/Cargo.toml
Normal file
52
crates/apub/Cargo.toml
Normal file
|
@ -0,0 +1,52 @@
|
|||
[package]
|
||||
name = "lemmy_apub"
|
||||
version = "0.1.0"
|
||||
edition = "2018"
|
||||
|
||||
[lib]
|
||||
name = "lemmy_apub"
|
||||
path = "src/lib.rs"
|
||||
doctest = false
|
||||
|
||||
[dependencies]
|
||||
lemmy_utils = { path = "../utils" }
|
||||
lemmy_db_queries = { path = "../db_queries" }
|
||||
lemmy_db_schema = { path = "../db_schema" }
|
||||
lemmy_db_views = { path = "../db_views" }
|
||||
lemmy_db_views_actor = { path = "../db_views_actor" }
|
||||
lemmy_api_structs = { path = "../api_structs" }
|
||||
lemmy_websocket = { path = "../websocket" }
|
||||
diesel = "1.4.5"
|
||||
activitystreams = "0.7.0-alpha.10"
|
||||
activitystreams-ext = "0.1.0-alpha.2"
|
||||
bcrypt = "0.9.0"
|
||||
chrono = { version = "0.4.19", features = ["serde"] }
|
||||
serde_json = { version = "1.0.61", features = ["preserve_order"] }
|
||||
serde = { version = "1.0.123", features = ["derive"] }
|
||||
actix = "0.10.0"
|
||||
actix-web = { version = "3.3.2", default-features = false }
|
||||
actix-rt = { version = "1.1.1", default-features = false }
|
||||
awc = { version = "2.0.3", default-features = false }
|
||||
log = "0.4.14"
|
||||
rand = "0.8.3"
|
||||
strum = "0.20.0"
|
||||
strum_macros = "0.20.1"
|
||||
lazy_static = "1.4.0"
|
||||
url = { version = "2.2.1", features = ["serde"] }
|
||||
percent-encoding = "2.1.0"
|
||||
openssl = "0.10.32"
|
||||
http = "0.2.3"
|
||||
http-signature-normalization-actix = { version = "0.4.1", default-features = false, features = ["sha-2"] }
|
||||
http-signature-normalization-reqwest = { version = "0.1.3", default-features = false, features = ["sha-2"] }
|
||||
base64 = "0.13.0"
|
||||
tokio = "0.3.6"
|
||||
futures = "0.3.12"
|
||||
itertools = "0.10.0"
|
||||
uuid = { version = "0.8.2", features = ["serde", "v4"] }
|
||||
sha2 = "0.9.3"
|
||||
async-trait = "0.1.42"
|
||||
anyhow = "1.0.38"
|
||||
thiserror = "1.0.23"
|
||||
background-jobs = "0.8.0"
|
||||
reqwest = { version = "0.10.10", features = ["json"] }
|
||||
backtrace = "0.3.56"
|
2
crates/apub/src/activities/mod.rs
Normal file
2
crates/apub/src/activities/mod.rs
Normal file
|
@ -0,0 +1,2 @@
|
|||
pub(crate) mod receive;
|
||||
pub(crate) mod send;
|
260
crates/apub/src/activities/receive/comment.rs
Normal file
260
crates/apub/src/activities/receive/comment.rs
Normal file
|
@ -0,0 +1,260 @@
|
|||
use crate::{activities::receive::get_actor_as_person, objects::FromApub, ActorType, NoteExt};
|
||||
use activitystreams::{
|
||||
activity::{ActorAndObjectRefExt, Create, Dislike, Like, Remove, Update},
|
||||
base::ExtendsExt,
|
||||
};
|
||||
use anyhow::Context;
|
||||
use lemmy_api_structs::{blocking, comment::CommentResponse, send_local_notifs};
|
||||
use lemmy_db_queries::{source::comment::Comment_, Crud, Likeable};
|
||||
use lemmy_db_schema::source::{
|
||||
comment::{Comment, CommentLike, CommentLikeForm},
|
||||
post::Post,
|
||||
};
|
||||
use lemmy_db_views::comment_view::CommentView;
|
||||
use lemmy_utils::{location_info, utils::scrape_text_for_mentions, LemmyError};
|
||||
use lemmy_websocket::{messages::SendComment, LemmyContext, UserOperation};
|
||||
|
||||
pub(crate) async fn receive_create_comment(
|
||||
create: Create,
|
||||
context: &LemmyContext,
|
||||
request_counter: &mut i32,
|
||||
) -> Result<(), LemmyError> {
|
||||
let person = get_actor_as_person(&create, context, request_counter).await?;
|
||||
let note = NoteExt::from_any_base(create.object().to_owned().one().context(location_info!())?)?
|
||||
.context(location_info!())?;
|
||||
|
||||
let comment = Comment::from_apub(¬e, context, person.actor_id(), request_counter).await?;
|
||||
|
||||
let post_id = comment.post_id;
|
||||
let post = blocking(context.pool(), move |conn| Post::read(conn, post_id)).await??;
|
||||
|
||||
// Note:
|
||||
// Although mentions could be gotten from the post tags (they are included there), or the ccs,
|
||||
// Its much easier to scrape them from the comment body, since the API has to do that
|
||||
// anyway.
|
||||
let mentions = scrape_text_for_mentions(&comment.content);
|
||||
let recipient_ids = send_local_notifs(
|
||||
mentions,
|
||||
comment.clone(),
|
||||
person,
|
||||
post,
|
||||
context.pool(),
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Refetch the view
|
||||
let comment_view = blocking(context.pool(), move |conn| {
|
||||
CommentView::read(conn, comment.id, None)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let res = CommentResponse {
|
||||
comment_view,
|
||||
recipient_ids,
|
||||
form_id: None,
|
||||
};
|
||||
|
||||
context.chat_server().do_send(SendComment {
|
||||
op: UserOperation::CreateComment,
|
||||
comment: res,
|
||||
websocket_id: None,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn receive_update_comment(
|
||||
update: Update,
|
||||
context: &LemmyContext,
|
||||
request_counter: &mut i32,
|
||||
) -> Result<(), LemmyError> {
|
||||
let note = NoteExt::from_any_base(update.object().to_owned().one().context(location_info!())?)?
|
||||
.context(location_info!())?;
|
||||
let person = get_actor_as_person(&update, context, request_counter).await?;
|
||||
|
||||
let comment = Comment::from_apub(¬e, context, person.actor_id(), request_counter).await?;
|
||||
|
||||
let comment_id = comment.id;
|
||||
let post_id = comment.post_id;
|
||||
let post = blocking(context.pool(), move |conn| Post::read(conn, post_id)).await??;
|
||||
|
||||
let mentions = scrape_text_for_mentions(&comment.content);
|
||||
let recipient_ids =
|
||||
send_local_notifs(mentions, comment, person, post, context.pool(), false).await?;
|
||||
|
||||
// Refetch the view
|
||||
let comment_view = blocking(context.pool(), move |conn| {
|
||||
CommentView::read(conn, comment_id, None)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let res = CommentResponse {
|
||||
comment_view,
|
||||
recipient_ids,
|
||||
form_id: None,
|
||||
};
|
||||
|
||||
context.chat_server().do_send(SendComment {
|
||||
op: UserOperation::EditComment,
|
||||
comment: res,
|
||||
websocket_id: None,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn receive_like_comment(
|
||||
like: Like,
|
||||
comment: Comment,
|
||||
context: &LemmyContext,
|
||||
request_counter: &mut i32,
|
||||
) -> Result<(), LemmyError> {
|
||||
let person = get_actor_as_person(&like, context, request_counter).await?;
|
||||
|
||||
let comment_id = comment.id;
|
||||
let like_form = CommentLikeForm {
|
||||
comment_id,
|
||||
post_id: comment.post_id,
|
||||
person_id: person.id,
|
||||
score: 1,
|
||||
};
|
||||
let person_id = person.id;
|
||||
blocking(context.pool(), move |conn| {
|
||||
CommentLike::remove(conn, person_id, comment_id)?;
|
||||
CommentLike::like(conn, &like_form)
|
||||
})
|
||||
.await??;
|
||||
|
||||
// Refetch the view
|
||||
let comment_view = blocking(context.pool(), move |conn| {
|
||||
CommentView::read(conn, comment_id, None)
|
||||
})
|
||||
.await??;
|
||||
|
||||
// TODO get those recipient actor ids from somewhere
|
||||
let recipient_ids = vec![];
|
||||
let res = CommentResponse {
|
||||
comment_view,
|
||||
recipient_ids,
|
||||
form_id: None,
|
||||
};
|
||||
|
||||
context.chat_server().do_send(SendComment {
|
||||
op: UserOperation::CreateCommentLike,
|
||||
comment: res,
|
||||
websocket_id: None,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn receive_dislike_comment(
|
||||
dislike: Dislike,
|
||||
comment: Comment,
|
||||
context: &LemmyContext,
|
||||
request_counter: &mut i32,
|
||||
) -> Result<(), LemmyError> {
|
||||
let person = get_actor_as_person(&dislike, context, request_counter).await?;
|
||||
|
||||
let comment_id = comment.id;
|
||||
let like_form = CommentLikeForm {
|
||||
comment_id,
|
||||
post_id: comment.post_id,
|
||||
person_id: person.id,
|
||||
score: -1,
|
||||
};
|
||||
let person_id = person.id;
|
||||
blocking(context.pool(), move |conn| {
|
||||
CommentLike::remove(conn, person_id, comment_id)?;
|
||||
CommentLike::like(conn, &like_form)
|
||||
})
|
||||
.await??;
|
||||
|
||||
// Refetch the view
|
||||
let comment_view = blocking(context.pool(), move |conn| {
|
||||
CommentView::read(conn, comment_id, None)
|
||||
})
|
||||
.await??;
|
||||
|
||||
// TODO get those recipient actor ids from somewhere
|
||||
let recipient_ids = vec![];
|
||||
let res = CommentResponse {
|
||||
comment_view,
|
||||
recipient_ids,
|
||||
form_id: None,
|
||||
};
|
||||
|
||||
context.chat_server().do_send(SendComment {
|
||||
op: UserOperation::CreateCommentLike,
|
||||
comment: res,
|
||||
websocket_id: None,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn receive_delete_comment(
|
||||
context: &LemmyContext,
|
||||
comment: Comment,
|
||||
) -> Result<(), LemmyError> {
|
||||
let deleted_comment = blocking(context.pool(), move |conn| {
|
||||
Comment::update_deleted(conn, comment.id, true)
|
||||
})
|
||||
.await??;
|
||||
|
||||
// Refetch the view
|
||||
let comment_id = deleted_comment.id;
|
||||
let comment_view = blocking(context.pool(), move |conn| {
|
||||
CommentView::read(conn, comment_id, None)
|
||||
})
|
||||
.await??;
|
||||
|
||||
// TODO get those recipient actor ids from somewhere
|
||||
let recipient_ids = vec![];
|
||||
let res = CommentResponse {
|
||||
comment_view,
|
||||
recipient_ids,
|
||||
form_id: None,
|
||||
};
|
||||
context.chat_server().do_send(SendComment {
|
||||
op: UserOperation::EditComment,
|
||||
comment: res,
|
||||
websocket_id: None,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn receive_remove_comment(
|
||||
context: &LemmyContext,
|
||||
_remove: Remove,
|
||||
comment: Comment,
|
||||
) -> Result<(), LemmyError> {
|
||||
let removed_comment = blocking(context.pool(), move |conn| {
|
||||
Comment::update_removed(conn, comment.id, true)
|
||||
})
|
||||
.await??;
|
||||
|
||||
// Refetch the view
|
||||
let comment_id = removed_comment.id;
|
||||
let comment_view = blocking(context.pool(), move |conn| {
|
||||
CommentView::read(conn, comment_id, None)
|
||||
})
|
||||
.await??;
|
||||
|
||||
// TODO get those recipient actor ids from somewhere
|
||||
let recipient_ids = vec![];
|
||||
let res = CommentResponse {
|
||||
comment_view,
|
||||
recipient_ids,
|
||||
form_id: None,
|
||||
};
|
||||
context.chat_server().do_send(SendComment {
|
||||
op: UserOperation::EditComment,
|
||||
comment: res,
|
||||
websocket_id: None,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
150
crates/apub/src/activities/receive/comment_undo.rs
Normal file
150
crates/apub/src/activities/receive/comment_undo.rs
Normal file
|
@ -0,0 +1,150 @@
|
|||
use crate::activities::receive::get_actor_as_person;
|
||||
use activitystreams::activity::{Dislike, Like};
|
||||
use lemmy_api_structs::{blocking, comment::CommentResponse};
|
||||
use lemmy_db_queries::{source::comment::Comment_, Likeable};
|
||||
use lemmy_db_schema::source::comment::{Comment, CommentLike};
|
||||
use lemmy_db_views::comment_view::CommentView;
|
||||
use lemmy_utils::LemmyError;
|
||||
use lemmy_websocket::{messages::SendComment, LemmyContext, UserOperation};
|
||||
|
||||
pub(crate) async fn receive_undo_like_comment(
|
||||
like: &Like,
|
||||
comment: Comment,
|
||||
context: &LemmyContext,
|
||||
request_counter: &mut i32,
|
||||
) -> Result<(), LemmyError> {
|
||||
let person = get_actor_as_person(like, context, request_counter).await?;
|
||||
|
||||
let comment_id = comment.id;
|
||||
let person_id = person.id;
|
||||
blocking(context.pool(), move |conn| {
|
||||
CommentLike::remove(conn, person_id, comment_id)
|
||||
})
|
||||
.await??;
|
||||
|
||||
// Refetch the view
|
||||
let comment_view = blocking(context.pool(), move |conn| {
|
||||
CommentView::read(conn, comment_id, None)
|
||||
})
|
||||
.await??;
|
||||
|
||||
// TODO get those recipient actor ids from somewhere
|
||||
let recipient_ids = vec![];
|
||||
let res = CommentResponse {
|
||||
comment_view,
|
||||
recipient_ids,
|
||||
form_id: None,
|
||||
};
|
||||
|
||||
context.chat_server().do_send(SendComment {
|
||||
op: UserOperation::CreateCommentLike,
|
||||
comment: res,
|
||||
websocket_id: None,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn receive_undo_dislike_comment(
|
||||
dislike: &Dislike,
|
||||
comment: Comment,
|
||||
context: &LemmyContext,
|
||||
request_counter: &mut i32,
|
||||
) -> Result<(), LemmyError> {
|
||||
let person = get_actor_as_person(dislike, context, request_counter).await?;
|
||||
|
||||
let comment_id = comment.id;
|
||||
let person_id = person.id;
|
||||
blocking(context.pool(), move |conn| {
|
||||
CommentLike::remove(conn, person_id, comment_id)
|
||||
})
|
||||
.await??;
|
||||
|
||||
// Refetch the view
|
||||
let comment_view = blocking(context.pool(), move |conn| {
|
||||
CommentView::read(conn, comment_id, None)
|
||||
})
|
||||
.await??;
|
||||
|
||||
// TODO get those recipient actor ids from somewhere
|
||||
let recipient_ids = vec![];
|
||||
let res = CommentResponse {
|
||||
comment_view,
|
||||
recipient_ids,
|
||||
form_id: None,
|
||||
};
|
||||
|
||||
context.chat_server().do_send(SendComment {
|
||||
op: UserOperation::CreateCommentLike,
|
||||
comment: res,
|
||||
websocket_id: None,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn receive_undo_delete_comment(
|
||||
context: &LemmyContext,
|
||||
comment: Comment,
|
||||
) -> Result<(), LemmyError> {
|
||||
let deleted_comment = blocking(context.pool(), move |conn| {
|
||||
Comment::update_deleted(conn, comment.id, false)
|
||||
})
|
||||
.await??;
|
||||
|
||||
// Refetch the view
|
||||
let comment_id = deleted_comment.id;
|
||||
let comment_view = blocking(context.pool(), move |conn| {
|
||||
CommentView::read(conn, comment_id, None)
|
||||
})
|
||||
.await??;
|
||||
|
||||
// TODO get those recipient actor ids from somewhere
|
||||
let recipient_ids = vec![];
|
||||
let res = CommentResponse {
|
||||
comment_view,
|
||||
recipient_ids,
|
||||
form_id: None,
|
||||
};
|
||||
|
||||
context.chat_server().do_send(SendComment {
|
||||
op: UserOperation::EditComment,
|
||||
comment: res,
|
||||
websocket_id: None,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn receive_undo_remove_comment(
|
||||
context: &LemmyContext,
|
||||
comment: Comment,
|
||||
) -> Result<(), LemmyError> {
|
||||
let removed_comment = blocking(context.pool(), move |conn| {
|
||||
Comment::update_removed(conn, comment.id, false)
|
||||
})
|
||||
.await??;
|
||||
|
||||
// Refetch the view
|
||||
let comment_id = removed_comment.id;
|
||||
let comment_view = blocking(context.pool(), move |conn| {
|
||||
CommentView::read(conn, comment_id, None)
|
||||
})
|
||||
.await??;
|
||||
|
||||
// TODO get those recipient actor ids from somewhere
|
||||
let recipient_ids = vec![];
|
||||
let res = CommentResponse {
|
||||
comment_view,
|
||||
recipient_ids,
|
||||
form_id: None,
|
||||
};
|
||||
|
||||
context.chat_server().do_send(SendComment {
|
||||
op: UserOperation::EditComment,
|
||||
comment: res,
|
||||
websocket_id: None,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
167
crates/apub/src/activities/receive/community.rs
Normal file
167
crates/apub/src/activities/receive/community.rs
Normal file
|
@ -0,0 +1,167 @@
|
|||
use crate::{activities::receive::verify_activity_domains_valid, inbox::is_addressed_to_public};
|
||||
use activitystreams::{
|
||||
activity::{ActorAndObjectRefExt, Delete, Remove, Undo},
|
||||
base::{AnyBase, ExtendsExt},
|
||||
};
|
||||
use anyhow::Context;
|
||||
use lemmy_api_structs::{blocking, community::CommunityResponse};
|
||||
use lemmy_db_queries::{source::community::Community_, ApubObject};
|
||||
use lemmy_db_schema::source::community::Community;
|
||||
use lemmy_db_views_actor::community_view::CommunityView;
|
||||
use lemmy_utils::{location_info, LemmyError};
|
||||
use lemmy_websocket::{messages::SendCommunityRoomMessage, LemmyContext, UserOperation};
|
||||
use url::Url;
|
||||
|
||||
pub(crate) async fn receive_delete_community(
|
||||
context: &LemmyContext,
|
||||
community: Community,
|
||||
) -> Result<(), LemmyError> {
|
||||
let deleted_community = blocking(context.pool(), move |conn| {
|
||||
Community::update_deleted(conn, community.id, true)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let community_id = deleted_community.id;
|
||||
let res = CommunityResponse {
|
||||
community_view: blocking(context.pool(), move |conn| {
|
||||
CommunityView::read(conn, community_id, None)
|
||||
})
|
||||
.await??,
|
||||
};
|
||||
|
||||
let community_id = res.community_view.community.id;
|
||||
context.chat_server().do_send(SendCommunityRoomMessage {
|
||||
op: UserOperation::EditCommunity,
|
||||
response: res,
|
||||
community_id,
|
||||
websocket_id: None,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn receive_remove_community(
|
||||
context: &LemmyContext,
|
||||
activity: AnyBase,
|
||||
expected_domain: &Url,
|
||||
) -> Result<(), LemmyError> {
|
||||
let remove = Remove::from_any_base(activity)?.context(location_info!())?;
|
||||
verify_activity_domains_valid(&remove, expected_domain, true)?;
|
||||
is_addressed_to_public(&remove)?;
|
||||
|
||||
let community_uri = remove
|
||||
.object()
|
||||
.to_owned()
|
||||
.single_xsd_any_uri()
|
||||
.context(location_info!())?;
|
||||
let community = blocking(context.pool(), move |conn| {
|
||||
Community::read_from_apub_id(conn, &community_uri.into())
|
||||
})
|
||||
.await??;
|
||||
|
||||
let removed_community = blocking(context.pool(), move |conn| {
|
||||
Community::update_removed(conn, community.id, true)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let community_id = removed_community.id;
|
||||
let res = CommunityResponse {
|
||||
community_view: blocking(context.pool(), move |conn| {
|
||||
CommunityView::read(conn, community_id, None)
|
||||
})
|
||||
.await??,
|
||||
};
|
||||
|
||||
let community_id = res.community_view.community.id;
|
||||
context.chat_server().do_send(SendCommunityRoomMessage {
|
||||
op: UserOperation::EditCommunity,
|
||||
response: res,
|
||||
community_id,
|
||||
websocket_id: None,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn receive_undo_delete_community(
|
||||
context: &LemmyContext,
|
||||
undo: Undo,
|
||||
community: Community,
|
||||
expected_domain: &Url,
|
||||
) -> Result<(), LemmyError> {
|
||||
is_addressed_to_public(&undo)?;
|
||||
let inner = undo.object().to_owned().one().context(location_info!())?;
|
||||
let delete = Delete::from_any_base(inner)?.context(location_info!())?;
|
||||
verify_activity_domains_valid(&delete, expected_domain, true)?;
|
||||
is_addressed_to_public(&delete)?;
|
||||
|
||||
let deleted_community = blocking(context.pool(), move |conn| {
|
||||
Community::update_deleted(conn, community.id, false)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let community_id = deleted_community.id;
|
||||
let res = CommunityResponse {
|
||||
community_view: blocking(context.pool(), move |conn| {
|
||||
CommunityView::read(conn, community_id, None)
|
||||
})
|
||||
.await??,
|
||||
};
|
||||
|
||||
let community_id = res.community_view.community.id;
|
||||
context.chat_server().do_send(SendCommunityRoomMessage {
|
||||
op: UserOperation::EditCommunity,
|
||||
response: res,
|
||||
community_id,
|
||||
websocket_id: None,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn receive_undo_remove_community(
|
||||
context: &LemmyContext,
|
||||
undo: Undo,
|
||||
expected_domain: &Url,
|
||||
) -> Result<(), LemmyError> {
|
||||
is_addressed_to_public(&undo)?;
|
||||
|
||||
let inner = undo.object().to_owned().one().context(location_info!())?;
|
||||
let remove = Remove::from_any_base(inner)?.context(location_info!())?;
|
||||
verify_activity_domains_valid(&remove, &expected_domain, true)?;
|
||||
is_addressed_to_public(&remove)?;
|
||||
|
||||
let community_uri = remove
|
||||
.object()
|
||||
.to_owned()
|
||||
.single_xsd_any_uri()
|
||||
.context(location_info!())?;
|
||||
let community = blocking(context.pool(), move |conn| {
|
||||
Community::read_from_apub_id(conn, &community_uri.into())
|
||||
})
|
||||
.await??;
|
||||
|
||||
let removed_community = blocking(context.pool(), move |conn| {
|
||||
Community::update_removed(conn, community.id, false)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let community_id = removed_community.id;
|
||||
let res = CommunityResponse {
|
||||
community_view: blocking(context.pool(), move |conn| {
|
||||
CommunityView::read(conn, community_id, None)
|
||||
})
|
||||
.await??,
|
||||
};
|
||||
|
||||
let community_id = res.community_view.community.id;
|
||||
|
||||
context.chat_server().do_send(SendCommunityRoomMessage {
|
||||
op: UserOperation::EditCommunity,
|
||||
response: res,
|
||||
community_id,
|
||||
websocket_id: None,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
81
crates/apub/src/activities/receive/mod.rs
Normal file
81
crates/apub/src/activities/receive/mod.rs
Normal file
|
@ -0,0 +1,81 @@
|
|||
use crate::fetcher::person::get_or_fetch_and_upsert_person;
|
||||
use activitystreams::{
|
||||
activity::{ActorAndObjectRef, ActorAndObjectRefExt},
|
||||
base::{AsBase, BaseExt},
|
||||
error::DomainError,
|
||||
};
|
||||
use anyhow::{anyhow, Context};
|
||||
use lemmy_db_schema::source::person::Person;
|
||||
use lemmy_utils::{location_info, LemmyError};
|
||||
use lemmy_websocket::LemmyContext;
|
||||
use log::debug;
|
||||
use std::fmt::Debug;
|
||||
use url::Url;
|
||||
|
||||
pub(crate) mod comment;
|
||||
pub(crate) mod comment_undo;
|
||||
pub(crate) mod community;
|
||||
pub(crate) mod post;
|
||||
pub(crate) mod post_undo;
|
||||
pub(crate) mod private_message;
|
||||
|
||||
/// Return HTTP 501 for unsupported activities in inbox.
|
||||
pub(crate) fn receive_unhandled_activity<A>(activity: A) -> Result<(), LemmyError>
|
||||
where
|
||||
A: Debug,
|
||||
{
|
||||
debug!("received unhandled activity type: {:?}", activity);
|
||||
Err(anyhow!("Activity not supported").into())
|
||||
}
|
||||
|
||||
/// Reads the actor field of an activity and returns the corresponding `Person`.
|
||||
pub(crate) async fn get_actor_as_person<T, A>(
|
||||
activity: &T,
|
||||
context: &LemmyContext,
|
||||
request_counter: &mut i32,
|
||||
) -> Result<Person, LemmyError>
|
||||
where
|
||||
T: AsBase<A> + ActorAndObjectRef,
|
||||
{
|
||||
let actor = activity.actor()?;
|
||||
let person_uri = actor.as_single_xsd_any_uri().context(location_info!())?;
|
||||
get_or_fetch_and_upsert_person(&person_uri, context, request_counter).await
|
||||
}
|
||||
|
||||
/// Ensure that the ID of an incoming activity comes from the same domain as the actor. Optionally
|
||||
/// also checks the ID of the inner object.
|
||||
///
|
||||
/// The reason that this starts with the actor ID is that it was already confirmed as correct by the
|
||||
/// HTTP signature.
|
||||
pub(crate) fn verify_activity_domains_valid<T, Kind>(
|
||||
activity: &T,
|
||||
actor_id: &Url,
|
||||
object_domain_must_match: bool,
|
||||
) -> Result<(), LemmyError>
|
||||
where
|
||||
T: AsBase<Kind> + ActorAndObjectRef,
|
||||
{
|
||||
let expected_domain = actor_id.domain().context(location_info!())?;
|
||||
|
||||
activity.id(expected_domain)?;
|
||||
|
||||
let object_id = match activity.object().to_owned().single_xsd_any_uri() {
|
||||
// object is just an ID
|
||||
Some(id) => id,
|
||||
// object is something like an activity, a comment or a post
|
||||
None => activity
|
||||
.object()
|
||||
.to_owned()
|
||||
.one()
|
||||
.context(location_info!())?
|
||||
.id()
|
||||
.context(location_info!())?
|
||||
.to_owned(),
|
||||
};
|
||||
|
||||
if object_domain_must_match && object_id.domain() != Some(expected_domain) {
|
||||
return Err(DomainError.into());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
199
crates/apub/src/activities/receive/post.rs
Normal file
199
crates/apub/src/activities/receive/post.rs
Normal file
|
@ -0,0 +1,199 @@
|
|||
use crate::{activities::receive::get_actor_as_person, objects::FromApub, ActorType, PageExt};
|
||||
use activitystreams::{
|
||||
activity::{Create, Dislike, Like, Remove, Update},
|
||||
prelude::*,
|
||||
};
|
||||
use anyhow::Context;
|
||||
use lemmy_api_structs::{blocking, post::PostResponse};
|
||||
use lemmy_db_queries::{source::post::Post_, Likeable};
|
||||
use lemmy_db_schema::source::post::{Post, PostLike, PostLikeForm};
|
||||
use lemmy_db_views::post_view::PostView;
|
||||
use lemmy_utils::{location_info, LemmyError};
|
||||
use lemmy_websocket::{messages::SendPost, LemmyContext, UserOperation};
|
||||
|
||||
pub(crate) async fn receive_create_post(
|
||||
create: Create,
|
||||
context: &LemmyContext,
|
||||
request_counter: &mut i32,
|
||||
) -> Result<(), LemmyError> {
|
||||
let person = get_actor_as_person(&create, context, request_counter).await?;
|
||||
let page = PageExt::from_any_base(create.object().to_owned().one().context(location_info!())?)?
|
||||
.context(location_info!())?;
|
||||
|
||||
let post = Post::from_apub(&page, context, person.actor_id(), request_counter).await?;
|
||||
|
||||
// Refetch the view
|
||||
let post_id = post.id;
|
||||
let post_view = blocking(context.pool(), move |conn| {
|
||||
PostView::read(conn, post_id, None)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let res = PostResponse { post_view };
|
||||
|
||||
context.chat_server().do_send(SendPost {
|
||||
op: UserOperation::CreatePost,
|
||||
post: res,
|
||||
websocket_id: None,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn receive_update_post(
|
||||
update: Update,
|
||||
context: &LemmyContext,
|
||||
request_counter: &mut i32,
|
||||
) -> Result<(), LemmyError> {
|
||||
let person = get_actor_as_person(&update, context, request_counter).await?;
|
||||
let page = PageExt::from_any_base(update.object().to_owned().one().context(location_info!())?)?
|
||||
.context(location_info!())?;
|
||||
|
||||
let post = Post::from_apub(&page, context, person.actor_id(), request_counter).await?;
|
||||
|
||||
let post_id = post.id;
|
||||
// Refetch the view
|
||||
let post_view = blocking(context.pool(), move |conn| {
|
||||
PostView::read(conn, post_id, None)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let res = PostResponse { post_view };
|
||||
|
||||
context.chat_server().do_send(SendPost {
|
||||
op: UserOperation::EditPost,
|
||||
post: res,
|
||||
websocket_id: None,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn receive_like_post(
|
||||
like: Like,
|
||||
post: Post,
|
||||
context: &LemmyContext,
|
||||
request_counter: &mut i32,
|
||||
) -> Result<(), LemmyError> {
|
||||
let person = get_actor_as_person(&like, context, request_counter).await?;
|
||||
|
||||
let post_id = post.id;
|
||||
let like_form = PostLikeForm {
|
||||
post_id,
|
||||
person_id: person.id,
|
||||
score: 1,
|
||||
};
|
||||
let person_id = person.id;
|
||||
blocking(context.pool(), move |conn| {
|
||||
PostLike::remove(conn, person_id, post_id)?;
|
||||
PostLike::like(conn, &like_form)
|
||||
})
|
||||
.await??;
|
||||
|
||||
// Refetch the view
|
||||
let post_view = blocking(context.pool(), move |conn| {
|
||||
PostView::read(conn, post_id, None)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let res = PostResponse { post_view };
|
||||
|
||||
context.chat_server().do_send(SendPost {
|
||||
op: UserOperation::CreatePostLike,
|
||||
post: res,
|
||||
websocket_id: None,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn receive_dislike_post(
|
||||
dislike: Dislike,
|
||||
post: Post,
|
||||
context: &LemmyContext,
|
||||
request_counter: &mut i32,
|
||||
) -> Result<(), LemmyError> {
|
||||
let person = get_actor_as_person(&dislike, context, request_counter).await?;
|
||||
|
||||
let post_id = post.id;
|
||||
let like_form = PostLikeForm {
|
||||
post_id,
|
||||
person_id: person.id,
|
||||
score: -1,
|
||||
};
|
||||
let person_id = person.id;
|
||||
blocking(context.pool(), move |conn| {
|
||||
PostLike::remove(conn, person_id, post_id)?;
|
||||
PostLike::like(conn, &like_form)
|
||||
})
|
||||
.await??;
|
||||
|
||||
// Refetch the view
|
||||
let post_view = blocking(context.pool(), move |conn| {
|
||||
PostView::read(conn, post_id, None)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let res = PostResponse { post_view };
|
||||
|
||||
context.chat_server().do_send(SendPost {
|
||||
op: UserOperation::CreatePostLike,
|
||||
post: res,
|
||||
websocket_id: None,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn receive_delete_post(
|
||||
context: &LemmyContext,
|
||||
post: Post,
|
||||
) -> Result<(), LemmyError> {
|
||||
let deleted_post = blocking(context.pool(), move |conn| {
|
||||
Post::update_deleted(conn, post.id, true)
|
||||
})
|
||||
.await??;
|
||||
|
||||
// Refetch the view
|
||||
let post_id = deleted_post.id;
|
||||
let post_view = blocking(context.pool(), move |conn| {
|
||||
PostView::read(conn, post_id, None)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let res = PostResponse { post_view };
|
||||
context.chat_server().do_send(SendPost {
|
||||
op: UserOperation::EditPost,
|
||||
post: res,
|
||||
websocket_id: None,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn receive_remove_post(
|
||||
context: &LemmyContext,
|
||||
_remove: Remove,
|
||||
post: Post,
|
||||
) -> Result<(), LemmyError> {
|
||||
let removed_post = blocking(context.pool(), move |conn| {
|
||||
Post::update_removed(conn, post.id, true)
|
||||
})
|
||||
.await??;
|
||||
|
||||
// Refetch the view
|
||||
let post_id = removed_post.id;
|
||||
let post_view = blocking(context.pool(), move |conn| {
|
||||
PostView::read(conn, post_id, None)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let res = PostResponse { post_view };
|
||||
context.chat_server().do_send(SendPost {
|
||||
op: UserOperation::EditPost,
|
||||
post: res,
|
||||
websocket_id: None,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
125
crates/apub/src/activities/receive/post_undo.rs
Normal file
125
crates/apub/src/activities/receive/post_undo.rs
Normal file
|
@ -0,0 +1,125 @@
|
|||
use crate::activities::receive::get_actor_as_person;
|
||||
use activitystreams::activity::{Dislike, Like};
|
||||
use lemmy_api_structs::{blocking, post::PostResponse};
|
||||
use lemmy_db_queries::{source::post::Post_, Likeable};
|
||||
use lemmy_db_schema::source::post::{Post, PostLike};
|
||||
use lemmy_db_views::post_view::PostView;
|
||||
use lemmy_utils::LemmyError;
|
||||
use lemmy_websocket::{messages::SendPost, LemmyContext, UserOperation};
|
||||
|
||||
pub(crate) async fn receive_undo_like_post(
|
||||
like: &Like,
|
||||
post: Post,
|
||||
context: &LemmyContext,
|
||||
request_counter: &mut i32,
|
||||
) -> Result<(), LemmyError> {
|
||||
let person = get_actor_as_person(like, context, request_counter).await?;
|
||||
|
||||
let post_id = post.id;
|
||||
let person_id = person.id;
|
||||
blocking(context.pool(), move |conn| {
|
||||
PostLike::remove(conn, person_id, post_id)
|
||||
})
|
||||
.await??;
|
||||
|
||||
// Refetch the view
|
||||
let post_view = blocking(context.pool(), move |conn| {
|
||||
PostView::read(conn, post_id, None)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let res = PostResponse { post_view };
|
||||
|
||||
context.chat_server().do_send(SendPost {
|
||||
op: UserOperation::CreatePostLike,
|
||||
post: res,
|
||||
websocket_id: None,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn receive_undo_dislike_post(
|
||||
dislike: &Dislike,
|
||||
post: Post,
|
||||
context: &LemmyContext,
|
||||
request_counter: &mut i32,
|
||||
) -> Result<(), LemmyError> {
|
||||
let person = get_actor_as_person(dislike, context, request_counter).await?;
|
||||
|
||||
let post_id = post.id;
|
||||
let person_id = person.id;
|
||||
blocking(context.pool(), move |conn| {
|
||||
PostLike::remove(conn, person_id, post_id)
|
||||
})
|
||||
.await??;
|
||||
|
||||
// Refetch the view
|
||||
let post_view = blocking(context.pool(), move |conn| {
|
||||
PostView::read(conn, post_id, None)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let res = PostResponse { post_view };
|
||||
|
||||
context.chat_server().do_send(SendPost {
|
||||
op: UserOperation::CreatePostLike,
|
||||
post: res,
|
||||
websocket_id: None,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn receive_undo_delete_post(
|
||||
context: &LemmyContext,
|
||||
post: Post,
|
||||
) -> Result<(), LemmyError> {
|
||||
let deleted_post = blocking(context.pool(), move |conn| {
|
||||
Post::update_deleted(conn, post.id, false)
|
||||
})
|
||||
.await??;
|
||||
|
||||
// Refetch the view
|
||||
let post_id = deleted_post.id;
|
||||
let post_view = blocking(context.pool(), move |conn| {
|
||||
PostView::read(conn, post_id, None)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let res = PostResponse { post_view };
|
||||
context.chat_server().do_send(SendPost {
|
||||
op: UserOperation::EditPost,
|
||||
post: res,
|
||||
websocket_id: None,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn receive_undo_remove_post(
|
||||
context: &LemmyContext,
|
||||
post: Post,
|
||||
) -> Result<(), LemmyError> {
|
||||
let removed_post = blocking(context.pool(), move |conn| {
|
||||
Post::update_removed(conn, post.id, false)
|
||||
})
|
||||
.await??;
|
||||
|
||||
// Refetch the view
|
||||
let post_id = removed_post.id;
|
||||
let post_view = blocking(context.pool(), move |conn| {
|
||||
PostView::read(conn, post_id, None)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let res = PostResponse { post_view };
|
||||
|
||||
context.chat_server().do_send(SendPost {
|
||||
op: UserOperation::EditPost,
|
||||
post: res,
|
||||
websocket_id: None,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
228
crates/apub/src/activities/receive/private_message.rs
Normal file
228
crates/apub/src/activities/receive/private_message.rs
Normal file
|
@ -0,0 +1,228 @@
|
|||
use crate::{
|
||||
activities::receive::verify_activity_domains_valid,
|
||||
check_is_apub_id_valid,
|
||||
fetcher::person::get_or_fetch_and_upsert_person,
|
||||
inbox::get_activity_to_and_cc,
|
||||
objects::FromApub,
|
||||
NoteExt,
|
||||
};
|
||||
use activitystreams::{
|
||||
activity::{ActorAndObjectRefExt, Create, Delete, Undo, Update},
|
||||
base::{AsBase, ExtendsExt},
|
||||
object::AsObject,
|
||||
public,
|
||||
};
|
||||
use anyhow::{anyhow, Context};
|
||||
use lemmy_api_structs::{blocking, person::PrivateMessageResponse};
|
||||
use lemmy_db_queries::source::private_message::PrivateMessage_;
|
||||
use lemmy_db_schema::source::private_message::PrivateMessage;
|
||||
use lemmy_db_views::{local_user_view::LocalUserView, private_message_view::PrivateMessageView};
|
||||
use lemmy_utils::{location_info, LemmyError};
|
||||
use lemmy_websocket::{messages::SendUserRoomMessage, LemmyContext, UserOperation};
|
||||
use url::Url;
|
||||
|
||||
pub(crate) async fn receive_create_private_message(
|
||||
context: &LemmyContext,
|
||||
create: Create,
|
||||
expected_domain: Url,
|
||||
request_counter: &mut i32,
|
||||
) -> Result<(), LemmyError> {
|
||||
check_private_message_activity_valid(&create, context, request_counter).await?;
|
||||
|
||||
let note = NoteExt::from_any_base(
|
||||
create
|
||||
.object()
|
||||
.as_one()
|
||||
.context(location_info!())?
|
||||
.to_owned(),
|
||||
)?
|
||||
.context(location_info!())?;
|
||||
|
||||
let private_message =
|
||||
PrivateMessage::from_apub(¬e, context, expected_domain, request_counter).await?;
|
||||
|
||||
let message = blocking(&context.pool(), move |conn| {
|
||||
PrivateMessageView::read(conn, private_message.id)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let res = PrivateMessageResponse {
|
||||
private_message_view: message,
|
||||
};
|
||||
|
||||
// Send notifications to the local recipient, if one exists
|
||||
let recipient_id = res.private_message_view.recipient.id;
|
||||
let local_recipient_id = blocking(context.pool(), move |conn| {
|
||||
LocalUserView::read_person(conn, recipient_id)
|
||||
})
|
||||
.await??
|
||||
.local_user
|
||||
.id;
|
||||
|
||||
context.chat_server().do_send(SendUserRoomMessage {
|
||||
op: UserOperation::CreatePrivateMessage,
|
||||
response: res,
|
||||
local_recipient_id,
|
||||
websocket_id: None,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn receive_update_private_message(
|
||||
context: &LemmyContext,
|
||||
update: Update,
|
||||
expected_domain: Url,
|
||||
request_counter: &mut i32,
|
||||
) -> Result<(), LemmyError> {
|
||||
check_private_message_activity_valid(&update, context, request_counter).await?;
|
||||
|
||||
let object = update
|
||||
.object()
|
||||
.as_one()
|
||||
.context(location_info!())?
|
||||
.to_owned();
|
||||
let note = NoteExt::from_any_base(object)?.context(location_info!())?;
|
||||
|
||||
let private_message =
|
||||
PrivateMessage::from_apub(¬e, context, expected_domain, request_counter).await?;
|
||||
|
||||
let private_message_id = private_message.id;
|
||||
let message = blocking(&context.pool(), move |conn| {
|
||||
PrivateMessageView::read(conn, private_message_id)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let res = PrivateMessageResponse {
|
||||
private_message_view: message,
|
||||
};
|
||||
|
||||
let recipient_id = res.private_message_view.recipient.id;
|
||||
let local_recipient_id = blocking(context.pool(), move |conn| {
|
||||
LocalUserView::read_person(conn, recipient_id)
|
||||
})
|
||||
.await??
|
||||
.local_user
|
||||
.id;
|
||||
|
||||
context.chat_server().do_send(SendUserRoomMessage {
|
||||
op: UserOperation::EditPrivateMessage,
|
||||
response: res,
|
||||
local_recipient_id,
|
||||
websocket_id: None,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn receive_delete_private_message(
|
||||
context: &LemmyContext,
|
||||
delete: Delete,
|
||||
private_message: PrivateMessage,
|
||||
request_counter: &mut i32,
|
||||
) -> Result<(), LemmyError> {
|
||||
check_private_message_activity_valid(&delete, context, request_counter).await?;
|
||||
|
||||
let deleted_private_message = blocking(context.pool(), move |conn| {
|
||||
PrivateMessage::update_deleted(conn, private_message.id, true)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let message = blocking(&context.pool(), move |conn| {
|
||||
PrivateMessageView::read(&conn, deleted_private_message.id)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let res = PrivateMessageResponse {
|
||||
private_message_view: message,
|
||||
};
|
||||
|
||||
let recipient_id = res.private_message_view.recipient.id;
|
||||
let local_recipient_id = blocking(context.pool(), move |conn| {
|
||||
LocalUserView::read_person(conn, recipient_id)
|
||||
})
|
||||
.await??
|
||||
.local_user
|
||||
.id;
|
||||
|
||||
context.chat_server().do_send(SendUserRoomMessage {
|
||||
op: UserOperation::EditPrivateMessage,
|
||||
response: res,
|
||||
local_recipient_id,
|
||||
websocket_id: None,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn receive_undo_delete_private_message(
|
||||
context: &LemmyContext,
|
||||
undo: Undo,
|
||||
expected_domain: &Url,
|
||||
private_message: PrivateMessage,
|
||||
request_counter: &mut i32,
|
||||
) -> Result<(), LemmyError> {
|
||||
check_private_message_activity_valid(&undo, context, request_counter).await?;
|
||||
let object = undo.object().to_owned().one().context(location_info!())?;
|
||||
let delete = Delete::from_any_base(object)?.context(location_info!())?;
|
||||
verify_activity_domains_valid(&delete, expected_domain, true)?;
|
||||
check_private_message_activity_valid(&delete, context, request_counter).await?;
|
||||
|
||||
let deleted_private_message = blocking(context.pool(), move |conn| {
|
||||
PrivateMessage::update_deleted(conn, private_message.id, false)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let message = blocking(&context.pool(), move |conn| {
|
||||
PrivateMessageView::read(&conn, deleted_private_message.id)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let res = PrivateMessageResponse {
|
||||
private_message_view: message,
|
||||
};
|
||||
|
||||
let recipient_id = res.private_message_view.recipient.id;
|
||||
let local_recipient_id = blocking(context.pool(), move |conn| {
|
||||
LocalUserView::read_person(conn, recipient_id)
|
||||
})
|
||||
.await??
|
||||
.local_user
|
||||
.id;
|
||||
|
||||
context.chat_server().do_send(SendUserRoomMessage {
|
||||
op: UserOperation::EditPrivateMessage,
|
||||
response: res,
|
||||
local_recipient_id,
|
||||
websocket_id: None,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn check_private_message_activity_valid<T, Kind>(
|
||||
activity: &T,
|
||||
context: &LemmyContext,
|
||||
request_counter: &mut i32,
|
||||
) -> Result<(), LemmyError>
|
||||
where
|
||||
T: AsBase<Kind> + AsObject<Kind> + ActorAndObjectRefExt,
|
||||
{
|
||||
let to_and_cc = get_activity_to_and_cc(activity);
|
||||
if to_and_cc.len() != 1 {
|
||||
return Err(anyhow!("Private message can only be addressed to one person").into());
|
||||
}
|
||||
if to_and_cc.contains(&public()) {
|
||||
return Err(anyhow!("Private message cant be public").into());
|
||||
}
|
||||
let person_id = activity
|
||||
.actor()?
|
||||
.to_owned()
|
||||
.single_xsd_any_uri()
|
||||
.context(location_info!())?;
|
||||
check_is_apub_id_valid(&person_id)?;
|
||||
// check that the sender is a person, not a community
|
||||
get_or_fetch_and_upsert_person(&person_id, &context, request_counter).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
440
crates/apub/src/activities/send/comment.rs
Normal file
440
crates/apub/src/activities/send/comment.rs
Normal file
|
@ -0,0 +1,440 @@
|
|||
use crate::{
|
||||
activities::send::generate_activity_id,
|
||||
activity_queue::{send_comment_mentions, send_to_community},
|
||||
extensions::context::lemmy_context,
|
||||
fetcher::person::get_or_fetch_and_upsert_person,
|
||||
objects::ToApub,
|
||||
ActorType,
|
||||
ApubLikeableType,
|
||||
ApubObjectType,
|
||||
};
|
||||
use activitystreams::{
|
||||
activity::{
|
||||
kind::{CreateType, DeleteType, DislikeType, LikeType, RemoveType, UndoType, UpdateType},
|
||||
Create,
|
||||
Delete,
|
||||
Dislike,
|
||||
Like,
|
||||
Remove,
|
||||
Undo,
|
||||
Update,
|
||||
},
|
||||
base::AnyBase,
|
||||
link::Mention,
|
||||
prelude::*,
|
||||
public,
|
||||
};
|
||||
use anyhow::anyhow;
|
||||
use itertools::Itertools;
|
||||
use lemmy_api_structs::{blocking, WebFingerResponse};
|
||||
use lemmy_db_queries::{Crud, DbPool};
|
||||
use lemmy_db_schema::source::{comment::Comment, community::Community, person::Person, post::Post};
|
||||
use lemmy_utils::{
|
||||
request::{retry, RecvError},
|
||||
settings::structs::Settings,
|
||||
utils::{scrape_text_for_mentions, MentionData},
|
||||
LemmyError,
|
||||
};
|
||||
use lemmy_websocket::LemmyContext;
|
||||
use log::debug;
|
||||
use reqwest::Client;
|
||||
use serde_json::Error;
|
||||
use url::Url;
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl ApubObjectType for Comment {
|
||||
/// Send out information about a newly created comment, to the followers of the community and
|
||||
/// mentioned persons.
|
||||
async fn send_create(&self, creator: &Person, context: &LemmyContext) -> Result<(), LemmyError> {
|
||||
let note = self.to_apub(context.pool()).await?;
|
||||
|
||||
let post_id = self.post_id;
|
||||
let post = blocking(context.pool(), move |conn| Post::read(conn, post_id)).await??;
|
||||
|
||||
let community_id = post.community_id;
|
||||
let community = blocking(context.pool(), move |conn| {
|
||||
Community::read(conn, community_id)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let maa = collect_non_local_mentions(&self, &community, context).await?;
|
||||
|
||||
let mut create = Create::new(
|
||||
creator.actor_id.to_owned().into_inner(),
|
||||
note.into_any_base()?,
|
||||
);
|
||||
create
|
||||
.set_many_contexts(lemmy_context()?)
|
||||
.set_id(generate_activity_id(CreateType::Create)?)
|
||||
.set_to(public())
|
||||
.set_many_ccs(maa.ccs.to_owned())
|
||||
// Set the mention tags
|
||||
.set_many_tags(maa.get_tags()?);
|
||||
|
||||
send_to_community(create.clone(), &creator, &community, context).await?;
|
||||
send_comment_mentions(&creator, maa.inboxes, create, context).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send out information about an edited post, to the followers of the community and mentioned
|
||||
/// persons.
|
||||
async fn send_update(&self, creator: &Person, context: &LemmyContext) -> Result<(), LemmyError> {
|
||||
let note = self.to_apub(context.pool()).await?;
|
||||
|
||||
let post_id = self.post_id;
|
||||
let post = blocking(context.pool(), move |conn| Post::read(conn, post_id)).await??;
|
||||
|
||||
let community_id = post.community_id;
|
||||
let community = blocking(context.pool(), move |conn| {
|
||||
Community::read(conn, community_id)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let maa = collect_non_local_mentions(&self, &community, context).await?;
|
||||
|
||||
let mut update = Update::new(
|
||||
creator.actor_id.to_owned().into_inner(),
|
||||
note.into_any_base()?,
|
||||
);
|
||||
update
|
||||
.set_many_contexts(lemmy_context()?)
|
||||
.set_id(generate_activity_id(UpdateType::Update)?)
|
||||
.set_to(public())
|
||||
.set_many_ccs(maa.ccs.to_owned())
|
||||
// Set the mention tags
|
||||
.set_many_tags(maa.get_tags()?);
|
||||
|
||||
send_to_community(update.clone(), &creator, &community, context).await?;
|
||||
send_comment_mentions(&creator, maa.inboxes, update, context).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_delete(&self, creator: &Person, context: &LemmyContext) -> Result<(), LemmyError> {
|
||||
let post_id = self.post_id;
|
||||
let post = blocking(context.pool(), move |conn| Post::read(conn, post_id)).await??;
|
||||
|
||||
let community_id = post.community_id;
|
||||
let community = blocking(context.pool(), move |conn| {
|
||||
Community::read(conn, community_id)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let mut delete = Delete::new(
|
||||
creator.actor_id.to_owned().into_inner(),
|
||||
self.ap_id.to_owned().into_inner(),
|
||||
);
|
||||
delete
|
||||
.set_many_contexts(lemmy_context()?)
|
||||
.set_id(generate_activity_id(DeleteType::Delete)?)
|
||||
.set_to(public())
|
||||
.set_many_ccs(vec![community.actor_id()]);
|
||||
|
||||
send_to_community(delete, &creator, &community, context).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_undo_delete(
|
||||
&self,
|
||||
creator: &Person,
|
||||
context: &LemmyContext,
|
||||
) -> Result<(), LemmyError> {
|
||||
let post_id = self.post_id;
|
||||
let post = blocking(context.pool(), move |conn| Post::read(conn, post_id)).await??;
|
||||
|
||||
let community_id = post.community_id;
|
||||
let community = blocking(context.pool(), move |conn| {
|
||||
Community::read(conn, community_id)
|
||||
})
|
||||
.await??;
|
||||
|
||||
// Generate a fake delete activity, with the correct object
|
||||
let mut delete = Delete::new(
|
||||
creator.actor_id.to_owned().into_inner(),
|
||||
self.ap_id.to_owned().into_inner(),
|
||||
);
|
||||
delete
|
||||
.set_many_contexts(lemmy_context()?)
|
||||
.set_id(generate_activity_id(DeleteType::Delete)?)
|
||||
.set_to(public())
|
||||
.set_many_ccs(vec![community.actor_id()]);
|
||||
|
||||
// Undo that fake activity
|
||||
let mut undo = Undo::new(
|
||||
creator.actor_id.to_owned().into_inner(),
|
||||
delete.into_any_base()?,
|
||||
);
|
||||
undo
|
||||
.set_many_contexts(lemmy_context()?)
|
||||
.set_id(generate_activity_id(UndoType::Undo)?)
|
||||
.set_to(public())
|
||||
.set_many_ccs(vec![community.actor_id()]);
|
||||
|
||||
send_to_community(undo, &creator, &community, context).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_remove(&self, mod_: &Person, context: &LemmyContext) -> Result<(), LemmyError> {
|
||||
let post_id = self.post_id;
|
||||
let post = blocking(context.pool(), move |conn| Post::read(conn, post_id)).await??;
|
||||
|
||||
let community_id = post.community_id;
|
||||
let community = blocking(context.pool(), move |conn| {
|
||||
Community::read(conn, community_id)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let mut remove = Remove::new(
|
||||
mod_.actor_id.to_owned().into_inner(),
|
||||
self.ap_id.to_owned().into_inner(),
|
||||
);
|
||||
remove
|
||||
.set_many_contexts(lemmy_context()?)
|
||||
.set_id(generate_activity_id(RemoveType::Remove)?)
|
||||
.set_to(public())
|
||||
.set_many_ccs(vec![community.actor_id()]);
|
||||
|
||||
send_to_community(remove, &mod_, &community, context).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_undo_remove(
|
||||
&self,
|
||||
mod_: &Person,
|
||||
context: &LemmyContext,
|
||||
) -> Result<(), LemmyError> {
|
||||
let post_id = self.post_id;
|
||||
let post = blocking(context.pool(), move |conn| Post::read(conn, post_id)).await??;
|
||||
|
||||
let community_id = post.community_id;
|
||||
let community = blocking(context.pool(), move |conn| {
|
||||
Community::read(conn, community_id)
|
||||
})
|
||||
.await??;
|
||||
|
||||
// Generate a fake delete activity, with the correct object
|
||||
let mut remove = Remove::new(
|
||||
mod_.actor_id.to_owned().into_inner(),
|
||||
self.ap_id.to_owned().into_inner(),
|
||||
);
|
||||
remove
|
||||
.set_many_contexts(lemmy_context()?)
|
||||
.set_id(generate_activity_id(RemoveType::Remove)?)
|
||||
.set_to(public())
|
||||
.set_many_ccs(vec![community.actor_id()]);
|
||||
|
||||
// Undo that fake activity
|
||||
let mut undo = Undo::new(
|
||||
mod_.actor_id.to_owned().into_inner(),
|
||||
remove.into_any_base()?,
|
||||
);
|
||||
undo
|
||||
.set_many_contexts(lemmy_context()?)
|
||||
.set_id(generate_activity_id(UndoType::Undo)?)
|
||||
.set_to(public())
|
||||
.set_many_ccs(vec![community.actor_id()]);
|
||||
|
||||
send_to_community(undo, &mod_, &community, context).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl ApubLikeableType for Comment {
|
||||
async fn send_like(&self, creator: &Person, context: &LemmyContext) -> Result<(), LemmyError> {
|
||||
let post_id = self.post_id;
|
||||
let post = blocking(context.pool(), move |conn| Post::read(conn, post_id)).await??;
|
||||
|
||||
let community_id = post.community_id;
|
||||
let community = blocking(context.pool(), move |conn| {
|
||||
Community::read(conn, community_id)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let mut like = Like::new(
|
||||
creator.actor_id.to_owned().into_inner(),
|
||||
self.ap_id.to_owned().into_inner(),
|
||||
);
|
||||
like
|
||||
.set_many_contexts(lemmy_context()?)
|
||||
.set_id(generate_activity_id(LikeType::Like)?)
|
||||
.set_to(public())
|
||||
.set_many_ccs(vec![community.actor_id()]);
|
||||
|
||||
send_to_community(like, &creator, &community, context).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_dislike(&self, creator: &Person, context: &LemmyContext) -> Result<(), LemmyError> {
|
||||
let post_id = self.post_id;
|
||||
let post = blocking(context.pool(), move |conn| Post::read(conn, post_id)).await??;
|
||||
|
||||
let community_id = post.community_id;
|
||||
let community = blocking(context.pool(), move |conn| {
|
||||
Community::read(conn, community_id)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let mut dislike = Dislike::new(
|
||||
creator.actor_id.to_owned().into_inner(),
|
||||
self.ap_id.to_owned().into_inner(),
|
||||
);
|
||||
dislike
|
||||
.set_many_contexts(lemmy_context()?)
|
||||
.set_id(generate_activity_id(DislikeType::Dislike)?)
|
||||
.set_to(public())
|
||||
.set_many_ccs(vec![community.actor_id()]);
|
||||
|
||||
send_to_community(dislike, &creator, &community, context).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_undo_like(
|
||||
&self,
|
||||
creator: &Person,
|
||||
context: &LemmyContext,
|
||||
) -> Result<(), LemmyError> {
|
||||
let post_id = self.post_id;
|
||||
let post = blocking(context.pool(), move |conn| Post::read(conn, post_id)).await??;
|
||||
|
||||
let community_id = post.community_id;
|
||||
let community = blocking(context.pool(), move |conn| {
|
||||
Community::read(conn, community_id)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let mut like = Like::new(
|
||||
creator.actor_id.to_owned().into_inner(),
|
||||
self.ap_id.to_owned().into_inner(),
|
||||
);
|
||||
like
|
||||
.set_many_contexts(lemmy_context()?)
|
||||
.set_id(generate_activity_id(DislikeType::Dislike)?)
|
||||
.set_to(public())
|
||||
.set_many_ccs(vec![community.actor_id()]);
|
||||
|
||||
// Undo that fake activity
|
||||
let mut undo = Undo::new(
|
||||
creator.actor_id.to_owned().into_inner(),
|
||||
like.into_any_base()?,
|
||||
);
|
||||
undo
|
||||
.set_many_contexts(lemmy_context()?)
|
||||
.set_id(generate_activity_id(UndoType::Undo)?)
|
||||
.set_to(public())
|
||||
.set_many_ccs(vec![community.actor_id()]);
|
||||
|
||||
send_to_community(undo, &creator, &community, context).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct MentionsAndAddresses {
|
||||
ccs: Vec<Url>,
|
||||
inboxes: Vec<Url>,
|
||||
tags: Vec<Mention>,
|
||||
}
|
||||
|
||||
impl MentionsAndAddresses {
|
||||
fn get_tags(&self) -> Result<Vec<AnyBase>, Error> {
|
||||
self
|
||||
.tags
|
||||
.iter()
|
||||
.map(|t| t.to_owned().into_any_base())
|
||||
.collect::<Result<Vec<AnyBase>, Error>>()
|
||||
}
|
||||
}
|
||||
|
||||
/// This takes a comment, and builds a list of to_addresses, inboxes,
|
||||
/// and mention tags, so they know where to be sent to.
|
||||
/// Addresses are the persons / addresses that go in the cc field.
|
||||
async fn collect_non_local_mentions(
|
||||
comment: &Comment,
|
||||
community: &Community,
|
||||
context: &LemmyContext,
|
||||
) -> Result<MentionsAndAddresses, LemmyError> {
|
||||
let parent_creator = get_comment_parent_creator(context.pool(), comment).await?;
|
||||
let mut addressed_ccs = vec![community.actor_id(), parent_creator.actor_id()];
|
||||
// Note: dont include community inbox here, as we send to it separately with `send_to_community()`
|
||||
let mut inboxes = vec![parent_creator.get_shared_inbox_or_inbox_url()];
|
||||
|
||||
// Add the mention tag
|
||||
let mut tags = Vec::new();
|
||||
|
||||
// Get the person IDs for any mentions
|
||||
let mentions = scrape_text_for_mentions(&comment.content)
|
||||
.into_iter()
|
||||
// Filter only the non-local ones
|
||||
.filter(|m| !m.is_local())
|
||||
.collect::<Vec<MentionData>>();
|
||||
|
||||
for mention in &mentions {
|
||||
// TODO should it be fetching it every time?
|
||||
if let Ok(actor_id) = fetch_webfinger_url(mention, context.client()).await {
|
||||
debug!("mention actor_id: {}", actor_id);
|
||||
addressed_ccs.push(actor_id.to_owned().to_string().parse()?);
|
||||
|
||||
let mention_person = get_or_fetch_and_upsert_person(&actor_id, context, &mut 0).await?;
|
||||
inboxes.push(mention_person.get_shared_inbox_or_inbox_url());
|
||||
|
||||
let mut mention_tag = Mention::new();
|
||||
mention_tag.set_href(actor_id).set_name(mention.full_name());
|
||||
tags.push(mention_tag);
|
||||
}
|
||||
}
|
||||
|
||||
let inboxes = inboxes.into_iter().unique().collect();
|
||||
|
||||
Ok(MentionsAndAddresses {
|
||||
ccs: addressed_ccs,
|
||||
inboxes,
|
||||
tags,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the apub ID of the person this comment is responding to. Meaning, in case this is a
|
||||
/// top-level comment, the creator of the post, otherwise the creator of the parent comment.
|
||||
async fn get_comment_parent_creator(
|
||||
pool: &DbPool,
|
||||
comment: &Comment,
|
||||
) -> Result<Person, LemmyError> {
|
||||
let parent_creator_id = if let Some(parent_comment_id) = comment.parent_id {
|
||||
let parent_comment =
|
||||
blocking(pool, move |conn| Comment::read(conn, parent_comment_id)).await??;
|
||||
parent_comment.creator_id
|
||||
} else {
|
||||
let parent_post_id = comment.post_id;
|
||||
let parent_post = blocking(pool, move |conn| Post::read(conn, parent_post_id)).await??;
|
||||
parent_post.creator_id
|
||||
};
|
||||
Ok(blocking(pool, move |conn| Person::read(conn, parent_creator_id)).await??)
|
||||
}
|
||||
|
||||
/// Turns a person id like `@name@example.com` into an apub ID, like `https://example.com/user/name`,
|
||||
/// using webfinger.
|
||||
async fn fetch_webfinger_url(mention: &MentionData, client: &Client) -> Result<Url, LemmyError> {
|
||||
let fetch_url = format!(
|
||||
"{}://{}/.well-known/webfinger?resource=acct:{}@{}",
|
||||
Settings::get().get_protocol_string(),
|
||||
mention.domain,
|
||||
mention.name,
|
||||
mention.domain
|
||||
);
|
||||
debug!("Fetching webfinger url: {}", &fetch_url);
|
||||
|
||||
let response = retry(|| client.get(&fetch_url).send()).await?;
|
||||
|
||||
let res: WebFingerResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| RecvError(e.to_string()))?;
|
||||
|
||||
let link = res
|
||||
.links
|
||||
.iter()
|
||||
.find(|l| l.type_.eq(&Some("application/activity+json".to_string())))
|
||||
.ok_or_else(|| anyhow!("No application/activity+json link found."))?;
|
||||
link
|
||||
.href
|
||||
.to_owned()
|
||||
.ok_or_else(|| anyhow!("No href found.").into())
|
||||
}
|
214
crates/apub/src/activities/send/community.rs
Normal file
214
crates/apub/src/activities/send/community.rs
Normal file
|
@ -0,0 +1,214 @@
|
|||
use crate::{
|
||||
activities::send::generate_activity_id,
|
||||
activity_queue::{send_activity_single_dest, send_to_community_followers},
|
||||
check_is_apub_id_valid,
|
||||
extensions::context::lemmy_context,
|
||||
fetcher::person::get_or_fetch_and_upsert_person,
|
||||
insert_activity,
|
||||
ActorType,
|
||||
};
|
||||
use activitystreams::{
|
||||
activity::{
|
||||
kind::{AcceptType, AnnounceType, DeleteType, LikeType, RemoveType, UndoType},
|
||||
Accept,
|
||||
ActorAndObjectRefExt,
|
||||
Announce,
|
||||
Delete,
|
||||
Follow,
|
||||
Remove,
|
||||
Undo,
|
||||
},
|
||||
base::{AnyBase, BaseExt, ExtendsExt},
|
||||
object::ObjectExt,
|
||||
public,
|
||||
};
|
||||
use anyhow::Context;
|
||||
use itertools::Itertools;
|
||||
use lemmy_api_structs::blocking;
|
||||
use lemmy_db_queries::DbPool;
|
||||
use lemmy_db_schema::source::community::Community;
|
||||
use lemmy_db_views_actor::community_follower_view::CommunityFollowerView;
|
||||
use lemmy_utils::{location_info, settings::structs::Settings, LemmyError};
|
||||
use lemmy_websocket::LemmyContext;
|
||||
use url::Url;
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl ActorType for Community {
|
||||
fn is_local(&self) -> bool {
|
||||
self.local
|
||||
}
|
||||
fn actor_id(&self) -> Url {
|
||||
self.actor_id.to_owned().into_inner()
|
||||
}
|
||||
fn public_key(&self) -> Option<String> {
|
||||
self.public_key.to_owned()
|
||||
}
|
||||
fn private_key(&self) -> Option<String> {
|
||||
self.private_key.to_owned()
|
||||
}
|
||||
|
||||
fn get_shared_inbox_or_inbox_url(&self) -> Url {
|
||||
self
|
||||
.shared_inbox_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.inbox_url.to_owned())
|
||||
.into()
|
||||
}
|
||||
|
||||
async fn send_follow(
|
||||
&self,
|
||||
_follow_actor_id: &Url,
|
||||
_context: &LemmyContext,
|
||||
) -> Result<(), LemmyError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn send_unfollow(
|
||||
&self,
|
||||
_follow_actor_id: &Url,
|
||||
_context: &LemmyContext,
|
||||
) -> Result<(), LemmyError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
/// As a local community, accept the follow request from a remote person.
|
||||
async fn send_accept_follow(
|
||||
&self,
|
||||
follow: Follow,
|
||||
context: &LemmyContext,
|
||||
) -> Result<(), LemmyError> {
|
||||
let actor_uri = follow
|
||||
.actor()?
|
||||
.as_single_xsd_any_uri()
|
||||
.context(location_info!())?;
|
||||
let person = get_or_fetch_and_upsert_person(actor_uri, context, &mut 0).await?;
|
||||
|
||||
let mut accept = Accept::new(
|
||||
self.actor_id.to_owned().into_inner(),
|
||||
follow.into_any_base()?,
|
||||
);
|
||||
accept
|
||||
.set_many_contexts(lemmy_context()?)
|
||||
.set_id(generate_activity_id(AcceptType::Accept)?)
|
||||
.set_to(person.actor_id());
|
||||
|
||||
send_activity_single_dest(accept, self, person.inbox_url.into(), context).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// If the creator of a community deletes the community, send this to all followers.
|
||||
async fn send_delete(&self, context: &LemmyContext) -> Result<(), LemmyError> {
|
||||
let mut delete = Delete::new(self.actor_id(), self.actor_id());
|
||||
delete
|
||||
.set_many_contexts(lemmy_context()?)
|
||||
.set_id(generate_activity_id(DeleteType::Delete)?)
|
||||
.set_to(public())
|
||||
.set_many_ccs(vec![self.followers_url.clone().into_inner()]);
|
||||
|
||||
send_to_community_followers(delete, self, context).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// If the creator of a community reverts the deletion of a community, send this to all followers.
|
||||
async fn send_undo_delete(&self, context: &LemmyContext) -> Result<(), LemmyError> {
|
||||
let mut delete = Delete::new(self.actor_id(), self.actor_id());
|
||||
delete
|
||||
.set_many_contexts(lemmy_context()?)
|
||||
.set_id(generate_activity_id(DeleteType::Delete)?)
|
||||
.set_to(public())
|
||||
.set_many_ccs(vec![self.followers_url.clone().into_inner()]);
|
||||
|
||||
let mut undo = Undo::new(self.actor_id(), delete.into_any_base()?);
|
||||
undo
|
||||
.set_many_contexts(lemmy_context()?)
|
||||
.set_id(generate_activity_id(UndoType::Undo)?)
|
||||
.set_to(public())
|
||||
.set_many_ccs(vec![self.followers_url.clone().into_inner()]);
|
||||
|
||||
send_to_community_followers(undo, self, context).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// If an admin removes a community, send this to all followers.
|
||||
async fn send_remove(&self, context: &LemmyContext) -> Result<(), LemmyError> {
|
||||
let mut remove = Remove::new(self.actor_id(), self.actor_id());
|
||||
remove
|
||||
.set_many_contexts(lemmy_context()?)
|
||||
.set_id(generate_activity_id(RemoveType::Remove)?)
|
||||
.set_to(public())
|
||||
.set_many_ccs(vec![self.followers_url.clone().into_inner()]);
|
||||
|
||||
send_to_community_followers(remove, self, context).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// If an admin reverts the removal of a community, send this to all followers.
|
||||
async fn send_undo_remove(&self, context: &LemmyContext) -> Result<(), LemmyError> {
|
||||
let mut remove = Remove::new(self.actor_id(), self.actor_id());
|
||||
remove
|
||||
.set_many_contexts(lemmy_context()?)
|
||||
.set_id(generate_activity_id(RemoveType::Remove)?)
|
||||
.set_to(public())
|
||||
.set_many_ccs(vec![self.followers_url.clone().into_inner()]);
|
||||
|
||||
// Undo that fake activity
|
||||
let mut undo = Undo::new(self.actor_id(), remove.into_any_base()?);
|
||||
undo
|
||||
.set_many_contexts(lemmy_context()?)
|
||||
.set_id(generate_activity_id(LikeType::Like)?)
|
||||
.set_to(public())
|
||||
.set_many_ccs(vec![self.followers_url.clone().into_inner()]);
|
||||
|
||||
send_to_community_followers(undo, self, context).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Wraps an activity sent to the community in an announce, and then sends the announce to all
|
||||
/// community followers.
|
||||
///
|
||||
/// If we are announcing a local activity, it hasn't been stored in the database yet, and we need
|
||||
/// to do it here, so that it can be fetched by ID. Remote activities are inserted into DB in the
|
||||
/// inbox.
|
||||
async fn send_announce(
|
||||
&self,
|
||||
activity: AnyBase,
|
||||
context: &LemmyContext,
|
||||
) -> Result<(), LemmyError> {
|
||||
let inner_id = activity.id().context(location_info!())?;
|
||||
if inner_id.domain() == Some(&Settings::get().get_hostname_without_port()?) {
|
||||
insert_activity(inner_id, activity.clone(), true, false, context.pool()).await?;
|
||||
}
|
||||
|
||||
let mut announce = Announce::new(self.actor_id.to_owned().into_inner(), activity);
|
||||
announce
|
||||
.set_many_contexts(lemmy_context()?)
|
||||
.set_id(generate_activity_id(AnnounceType::Announce)?)
|
||||
.set_to(public())
|
||||
.set_many_ccs(vec![self.followers_url.clone().into_inner()]);
|
||||
|
||||
send_to_community_followers(announce, self, context).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// For a given community, returns the inboxes of all followers.
|
||||
async fn get_follower_inboxes(&self, pool: &DbPool) -> Result<Vec<Url>, LemmyError> {
|
||||
let id = self.id;
|
||||
|
||||
let follows = blocking(pool, move |conn| {
|
||||
CommunityFollowerView::for_community(conn, id)
|
||||
})
|
||||
.await??;
|
||||
let inboxes = follows
|
||||
.into_iter()
|
||||
.filter(|f| !f.follower.local)
|
||||
.map(|f| f.follower.shared_inbox_url.unwrap_or(f.follower.inbox_url))
|
||||
.map(|i| i.into_inner())
|
||||
.unique()
|
||||
// Don't send to blocked instances
|
||||
.filter(|inbox| check_is_apub_id_valid(inbox).is_ok())
|
||||
.collect();
|
||||
|
||||
Ok(inboxes)
|
||||
}
|
||||
}
|
24
crates/apub/src/activities/send/mod.rs
Normal file
24
crates/apub/src/activities/send/mod.rs
Normal file
|
@ -0,0 +1,24 @@
|
|||
use lemmy_utils::settings::structs::Settings;
|
||||
use url::{ParseError, Url};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub(crate) mod comment;
|
||||
pub(crate) mod community;
|
||||
pub(crate) mod person;
|
||||
pub(crate) mod post;
|
||||
pub(crate) mod private_message;
|
||||
|
||||
/// Generate a unique ID for an activity, in the format:
|
||||
/// `http(s)://example.com/receive/create/202daf0a-1489-45df-8d2e-c8a3173fed36`
|
||||
fn generate_activity_id<T>(kind: T) -> Result<Url, ParseError>
|
||||
where
|
||||
T: ToString,
|
||||
{
|
||||
let id = format!(
|
||||
"{}/activities/{}/{}",
|
||||
Settings::get().get_protocol_and_hostname(),
|
||||
kind.to_string().to_lowercase(),
|
||||
Uuid::new_v4()
|
||||
);
|
||||
Url::parse(&id)
|
||||
}
|
149
crates/apub/src/activities/send/person.rs
Normal file
149
crates/apub/src/activities/send/person.rs
Normal file
|
@ -0,0 +1,149 @@
|
|||
use crate::{
|
||||
activities::send::generate_activity_id,
|
||||
activity_queue::send_activity_single_dest,
|
||||
extensions::context::lemmy_context,
|
||||
ActorType,
|
||||
};
|
||||
use activitystreams::{
|
||||
activity::{
|
||||
kind::{FollowType, UndoType},
|
||||
Follow,
|
||||
Undo,
|
||||
},
|
||||
base::{AnyBase, BaseExt, ExtendsExt},
|
||||
object::ObjectExt,
|
||||
};
|
||||
use lemmy_api_structs::blocking;
|
||||
use lemmy_db_queries::{ApubObject, DbPool, Followable};
|
||||
use lemmy_db_schema::source::{
|
||||
community::{Community, CommunityFollower, CommunityFollowerForm},
|
||||
person::Person,
|
||||
};
|
||||
use lemmy_utils::LemmyError;
|
||||
use lemmy_websocket::LemmyContext;
|
||||
use url::Url;
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl ActorType for Person {
|
||||
fn is_local(&self) -> bool {
|
||||
self.local
|
||||
}
|
||||
fn actor_id(&self) -> Url {
|
||||
self.actor_id.to_owned().into_inner()
|
||||
}
|
||||
|
||||
fn public_key(&self) -> Option<String> {
|
||||
self.public_key.to_owned()
|
||||
}
|
||||
|
||||
fn private_key(&self) -> Option<String> {
|
||||
self.private_key.to_owned()
|
||||
}
|
||||
|
||||
fn get_shared_inbox_or_inbox_url(&self) -> Url {
|
||||
self
|
||||
.shared_inbox_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.inbox_url.to_owned())
|
||||
.into()
|
||||
}
|
||||
|
||||
/// As a given local person, send out a follow request to a remote community.
|
||||
async fn send_follow(
|
||||
&self,
|
||||
follow_actor_id: &Url,
|
||||
context: &LemmyContext,
|
||||
) -> Result<(), LemmyError> {
|
||||
let follow_actor_id = follow_actor_id.to_owned();
|
||||
let community = blocking(context.pool(), move |conn| {
|
||||
Community::read_from_apub_id(conn, &follow_actor_id.into())
|
||||
})
|
||||
.await??;
|
||||
|
||||
let community_follower_form = CommunityFollowerForm {
|
||||
community_id: community.id,
|
||||
person_id: self.id,
|
||||
pending: true,
|
||||
};
|
||||
blocking(&context.pool(), move |conn| {
|
||||
CommunityFollower::follow(conn, &community_follower_form).ok()
|
||||
})
|
||||
.await?;
|
||||
|
||||
let mut follow = Follow::new(self.actor_id.to_owned().into_inner(), community.actor_id());
|
||||
follow
|
||||
.set_many_contexts(lemmy_context()?)
|
||||
.set_id(generate_activity_id(FollowType::Follow)?)
|
||||
.set_to(community.actor_id());
|
||||
|
||||
send_activity_single_dest(follow, self, community.inbox_url.into(), context).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_unfollow(
|
||||
&self,
|
||||
follow_actor_id: &Url,
|
||||
context: &LemmyContext,
|
||||
) -> Result<(), LemmyError> {
|
||||
let follow_actor_id = follow_actor_id.to_owned();
|
||||
let community = blocking(context.pool(), move |conn| {
|
||||
Community::read_from_apub_id(conn, &follow_actor_id.into())
|
||||
})
|
||||
.await??;
|
||||
|
||||
let mut follow = Follow::new(self.actor_id.to_owned().into_inner(), community.actor_id());
|
||||
follow
|
||||
.set_many_contexts(lemmy_context()?)
|
||||
.set_id(generate_activity_id(FollowType::Follow)?)
|
||||
.set_to(community.actor_id());
|
||||
|
||||
// Undo that fake activity
|
||||
let mut undo = Undo::new(
|
||||
self.actor_id.to_owned().into_inner(),
|
||||
follow.into_any_base()?,
|
||||
);
|
||||
undo
|
||||
.set_many_contexts(lemmy_context()?)
|
||||
.set_id(generate_activity_id(UndoType::Undo)?)
|
||||
.set_to(community.actor_id());
|
||||
|
||||
send_activity_single_dest(undo, self, community.inbox_url.into(), context).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_accept_follow(
|
||||
&self,
|
||||
_follow: Follow,
|
||||
_context: &LemmyContext,
|
||||
) -> Result<(), LemmyError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn send_delete(&self, _context: &LemmyContext) -> Result<(), LemmyError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn send_undo_delete(&self, _context: &LemmyContext) -> Result<(), LemmyError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn send_remove(&self, _context: &LemmyContext) -> Result<(), LemmyError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn send_undo_remove(&self, _context: &LemmyContext) -> Result<(), LemmyError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn send_announce(
|
||||
&self,
|
||||
_activity: AnyBase,
|
||||
_context: &LemmyContext,
|
||||
) -> Result<(), LemmyError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_follower_inboxes(&self, _pool: &DbPool) -> Result<Vec<Url>, LemmyError> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
274
crates/apub/src/activities/send/post.rs
Normal file
274
crates/apub/src/activities/send/post.rs
Normal file
|
@ -0,0 +1,274 @@
|
|||
use crate::{
|
||||
activities::send::generate_activity_id,
|
||||
activity_queue::send_to_community,
|
||||
extensions::context::lemmy_context,
|
||||
objects::ToApub,
|
||||
ActorType,
|
||||
ApubLikeableType,
|
||||
ApubObjectType,
|
||||
};
|
||||
use activitystreams::{
|
||||
activity::{
|
||||
kind::{CreateType, DeleteType, DislikeType, LikeType, RemoveType, UndoType, UpdateType},
|
||||
Create,
|
||||
Delete,
|
||||
Dislike,
|
||||
Like,
|
||||
Remove,
|
||||
Undo,
|
||||
Update,
|
||||
},
|
||||
prelude::*,
|
||||
public,
|
||||
};
|
||||
use lemmy_api_structs::blocking;
|
||||
use lemmy_db_queries::Crud;
|
||||
use lemmy_db_schema::source::{community::Community, person::Person, post::Post};
|
||||
use lemmy_utils::LemmyError;
|
||||
use lemmy_websocket::LemmyContext;
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl ApubObjectType for Post {
|
||||
/// Send out information about a newly created post, to the followers of the community.
|
||||
async fn send_create(&self, creator: &Person, context: &LemmyContext) -> Result<(), LemmyError> {
|
||||
let page = self.to_apub(context.pool()).await?;
|
||||
|
||||
let community_id = self.community_id;
|
||||
let community = blocking(context.pool(), move |conn| {
|
||||
Community::read(conn, community_id)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let mut create = Create::new(
|
||||
creator.actor_id.to_owned().into_inner(),
|
||||
page.into_any_base()?,
|
||||
);
|
||||
create
|
||||
.set_many_contexts(lemmy_context()?)
|
||||
.set_id(generate_activity_id(CreateType::Create)?)
|
||||
.set_to(public())
|
||||
.set_many_ccs(vec![community.actor_id()]);
|
||||
|
||||
send_to_community(create, creator, &community, context).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send out information about an edited post, to the followers of the community.
|
||||
async fn send_update(&self, creator: &Person, context: &LemmyContext) -> Result<(), LemmyError> {
|
||||
let page = self.to_apub(context.pool()).await?;
|
||||
|
||||
let community_id = self.community_id;
|
||||
let community = blocking(context.pool(), move |conn| {
|
||||
Community::read(conn, community_id)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let mut update = Update::new(
|
||||
creator.actor_id.to_owned().into_inner(),
|
||||
page.into_any_base()?,
|
||||
);
|
||||
update
|
||||
.set_many_contexts(lemmy_context()?)
|
||||
.set_id(generate_activity_id(UpdateType::Update)?)
|
||||
.set_to(public())
|
||||
.set_many_ccs(vec![community.actor_id()]);
|
||||
|
||||
send_to_community(update, creator, &community, context).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_delete(&self, creator: &Person, context: &LemmyContext) -> Result<(), LemmyError> {
|
||||
let community_id = self.community_id;
|
||||
let community = blocking(context.pool(), move |conn| {
|
||||
Community::read(conn, community_id)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let mut delete = Delete::new(
|
||||
creator.actor_id.to_owned().into_inner(),
|
||||
self.ap_id.to_owned().into_inner(),
|
||||
);
|
||||
delete
|
||||
.set_many_contexts(lemmy_context()?)
|
||||
.set_id(generate_activity_id(DeleteType::Delete)?)
|
||||
.set_to(public())
|
||||
.set_many_ccs(vec![community.actor_id()]);
|
||||
|
||||
send_to_community(delete, creator, &community, context).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_undo_delete(
|
||||
&self,
|
||||
creator: &Person,
|
||||
context: &LemmyContext,
|
||||
) -> Result<(), LemmyError> {
|
||||
let community_id = self.community_id;
|
||||
let community = blocking(context.pool(), move |conn| {
|
||||
Community::read(conn, community_id)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let mut delete = Delete::new(
|
||||
creator.actor_id.to_owned().into_inner(),
|
||||
self.ap_id.to_owned().into_inner(),
|
||||
);
|
||||
delete
|
||||
.set_many_contexts(lemmy_context()?)
|
||||
.set_id(generate_activity_id(DeleteType::Delete)?)
|
||||
.set_to(public())
|
||||
.set_many_ccs(vec![community.actor_id()]);
|
||||
|
||||
// Undo that fake activity
|
||||
let mut undo = Undo::new(
|
||||
creator.actor_id.to_owned().into_inner(),
|
||||
delete.into_any_base()?,
|
||||
);
|
||||
undo
|
||||
.set_many_contexts(lemmy_context()?)
|
||||
.set_id(generate_activity_id(UndoType::Undo)?)
|
||||
.set_to(public())
|
||||
.set_many_ccs(vec![community.actor_id()]);
|
||||
|
||||
send_to_community(undo, creator, &community, context).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_remove(&self, mod_: &Person, context: &LemmyContext) -> Result<(), LemmyError> {
|
||||
let community_id = self.community_id;
|
||||
let community = blocking(context.pool(), move |conn| {
|
||||
Community::read(conn, community_id)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let mut remove = Remove::new(
|
||||
mod_.actor_id.to_owned().into_inner(),
|
||||
self.ap_id.to_owned().into_inner(),
|
||||
);
|
||||
remove
|
||||
.set_many_contexts(lemmy_context()?)
|
||||
.set_id(generate_activity_id(RemoveType::Remove)?)
|
||||
.set_to(public())
|
||||
.set_many_ccs(vec![community.actor_id()]);
|
||||
|
||||
send_to_community(remove, mod_, &community, context).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_undo_remove(
|
||||
&self,
|
||||
mod_: &Person,
|
||||
context: &LemmyContext,
|
||||
) -> Result<(), LemmyError> {
|
||||
let community_id = self.community_id;
|
||||
let community = blocking(context.pool(), move |conn| {
|
||||
Community::read(conn, community_id)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let mut remove = Remove::new(
|
||||
mod_.actor_id.to_owned().into_inner(),
|
||||
self.ap_id.to_owned().into_inner(),
|
||||
);
|
||||
remove
|
||||
.set_many_contexts(lemmy_context()?)
|
||||
.set_id(generate_activity_id(RemoveType::Remove)?)
|
||||
.set_to(public())
|
||||
.set_many_ccs(vec![community.actor_id()]);
|
||||
|
||||
// Undo that fake activity
|
||||
let mut undo = Undo::new(
|
||||
mod_.actor_id.to_owned().into_inner(),
|
||||
remove.into_any_base()?,
|
||||
);
|
||||
undo
|
||||
.set_many_contexts(lemmy_context()?)
|
||||
.set_id(generate_activity_id(UndoType::Undo)?)
|
||||
.set_to(public())
|
||||
.set_many_ccs(vec![community.actor_id()]);
|
||||
|
||||
send_to_community(undo, mod_, &community, context).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl ApubLikeableType for Post {
|
||||
async fn send_like(&self, creator: &Person, context: &LemmyContext) -> Result<(), LemmyError> {
|
||||
let community_id = self.community_id;
|
||||
let community = blocking(context.pool(), move |conn| {
|
||||
Community::read(conn, community_id)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let mut like = Like::new(
|
||||
creator.actor_id.to_owned().into_inner(),
|
||||
self.ap_id.to_owned().into_inner(),
|
||||
);
|
||||
like
|
||||
.set_many_contexts(lemmy_context()?)
|
||||
.set_id(generate_activity_id(LikeType::Like)?)
|
||||
.set_to(public())
|
||||
.set_many_ccs(vec![community.actor_id()]);
|
||||
|
||||
send_to_community(like, &creator, &community, context).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_dislike(&self, creator: &Person, context: &LemmyContext) -> Result<(), LemmyError> {
|
||||
let community_id = self.community_id;
|
||||
let community = blocking(context.pool(), move |conn| {
|
||||
Community::read(conn, community_id)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let mut dislike = Dislike::new(
|
||||
creator.actor_id.to_owned().into_inner(),
|
||||
self.ap_id.to_owned().into_inner(),
|
||||
);
|
||||
dislike
|
||||
.set_many_contexts(lemmy_context()?)
|
||||
.set_id(generate_activity_id(DislikeType::Dislike)?)
|
||||
.set_to(public())
|
||||
.set_many_ccs(vec![community.actor_id()]);
|
||||
|
||||
send_to_community(dislike, &creator, &community, context).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_undo_like(
|
||||
&self,
|
||||
creator: &Person,
|
||||
context: &LemmyContext,
|
||||
) -> Result<(), LemmyError> {
|
||||
let community_id = self.community_id;
|
||||
let community = blocking(context.pool(), move |conn| {
|
||||
Community::read(conn, community_id)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let mut like = Like::new(
|
||||
creator.actor_id.to_owned().into_inner(),
|
||||
self.ap_id.to_owned().into_inner(),
|
||||
);
|
||||
like
|
||||
.set_many_contexts(lemmy_context()?)
|
||||
.set_id(generate_activity_id(LikeType::Like)?)
|
||||
.set_to(public())
|
||||
.set_many_ccs(vec![community.actor_id()]);
|
||||
|
||||
// Undo that fake activity
|
||||
let mut undo = Undo::new(
|
||||
creator.actor_id.to_owned().into_inner(),
|
||||
like.into_any_base()?,
|
||||
);
|
||||
undo
|
||||
.set_many_contexts(lemmy_context()?)
|
||||
.set_id(generate_activity_id(UndoType::Undo)?)
|
||||
.set_to(public())
|
||||
.set_many_ccs(vec![community.actor_id()]);
|
||||
|
||||
send_to_community(undo, &creator, &community, context).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
131
crates/apub/src/activities/send/private_message.rs
Normal file
131
crates/apub/src/activities/send/private_message.rs
Normal file
|
@ -0,0 +1,131 @@
|
|||
use crate::{
|
||||
activities::send::generate_activity_id,
|
||||
activity_queue::send_activity_single_dest,
|
||||
extensions::context::lemmy_context,
|
||||
objects::ToApub,
|
||||
ActorType,
|
||||
ApubObjectType,
|
||||
};
|
||||
use activitystreams::{
|
||||
activity::{
|
||||
kind::{CreateType, DeleteType, UndoType, UpdateType},
|
||||
Create,
|
||||
Delete,
|
||||
Undo,
|
||||
Update,
|
||||
},
|
||||
prelude::*,
|
||||
};
|
||||
use lemmy_api_structs::blocking;
|
||||
use lemmy_db_queries::Crud;
|
||||
use lemmy_db_schema::source::{person::Person, private_message::PrivateMessage};
|
||||
use lemmy_utils::LemmyError;
|
||||
use lemmy_websocket::LemmyContext;
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl ApubObjectType for PrivateMessage {
|
||||
/// Send out information about a newly created private message
|
||||
async fn send_create(&self, creator: &Person, context: &LemmyContext) -> Result<(), LemmyError> {
|
||||
let note = self.to_apub(context.pool()).await?;
|
||||
|
||||
let recipient_id = self.recipient_id;
|
||||
let recipient =
|
||||
blocking(context.pool(), move |conn| Person::read(conn, recipient_id)).await??;
|
||||
|
||||
let mut create = Create::new(
|
||||
creator.actor_id.to_owned().into_inner(),
|
||||
note.into_any_base()?,
|
||||
);
|
||||
|
||||
create
|
||||
.set_many_contexts(lemmy_context()?)
|
||||
.set_id(generate_activity_id(CreateType::Create)?)
|
||||
.set_to(recipient.actor_id());
|
||||
|
||||
send_activity_single_dest(create, creator, recipient.inbox_url.into(), context).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send out information about an edited private message, to the followers of the community.
|
||||
async fn send_update(&self, creator: &Person, context: &LemmyContext) -> Result<(), LemmyError> {
|
||||
let note = self.to_apub(context.pool()).await?;
|
||||
|
||||
let recipient_id = self.recipient_id;
|
||||
let recipient =
|
||||
blocking(context.pool(), move |conn| Person::read(conn, recipient_id)).await??;
|
||||
|
||||
let mut update = Update::new(
|
||||
creator.actor_id.to_owned().into_inner(),
|
||||
note.into_any_base()?,
|
||||
);
|
||||
update
|
||||
.set_many_contexts(lemmy_context()?)
|
||||
.set_id(generate_activity_id(UpdateType::Update)?)
|
||||
.set_to(recipient.actor_id());
|
||||
|
||||
send_activity_single_dest(update, creator, recipient.inbox_url.into(), context).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_delete(&self, creator: &Person, context: &LemmyContext) -> Result<(), LemmyError> {
|
||||
let recipient_id = self.recipient_id;
|
||||
let recipient =
|
||||
blocking(context.pool(), move |conn| Person::read(conn, recipient_id)).await??;
|
||||
|
||||
let mut delete = Delete::new(
|
||||
creator.actor_id.to_owned().into_inner(),
|
||||
self.ap_id.to_owned().into_inner(),
|
||||
);
|
||||
delete
|
||||
.set_many_contexts(lemmy_context()?)
|
||||
.set_id(generate_activity_id(DeleteType::Delete)?)
|
||||
.set_to(recipient.actor_id());
|
||||
|
||||
send_activity_single_dest(delete, creator, recipient.inbox_url.into(), context).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_undo_delete(
|
||||
&self,
|
||||
creator: &Person,
|
||||
context: &LemmyContext,
|
||||
) -> Result<(), LemmyError> {
|
||||
let recipient_id = self.recipient_id;
|
||||
let recipient =
|
||||
blocking(context.pool(), move |conn| Person::read(conn, recipient_id)).await??;
|
||||
|
||||
let mut delete = Delete::new(
|
||||
creator.actor_id.to_owned().into_inner(),
|
||||
self.ap_id.to_owned().into_inner(),
|
||||
);
|
||||
delete
|
||||
.set_many_contexts(lemmy_context()?)
|
||||
.set_id(generate_activity_id(DeleteType::Delete)?)
|
||||
.set_to(recipient.actor_id());
|
||||
|
||||
// Undo that fake activity
|
||||
let mut undo = Undo::new(
|
||||
creator.actor_id.to_owned().into_inner(),
|
||||
delete.into_any_base()?,
|
||||
);
|
||||
undo
|
||||
.set_many_contexts(lemmy_context()?)
|
||||
.set_id(generate_activity_id(UndoType::Undo)?)
|
||||
.set_to(recipient.actor_id());
|
||||
|
||||
send_activity_single_dest(undo, creator, recipient.inbox_url.into(), context).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_remove(&self, _mod_: &Person, _context: &LemmyContext) -> Result<(), LemmyError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn send_undo_remove(
|
||||
&self,
|
||||
_mod_: &Person,
|
||||
_context: &LemmyContext,
|
||||
) -> Result<(), LemmyError> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
320
crates/apub/src/activity_queue.rs
Normal file
320
crates/apub/src/activity_queue.rs
Normal file
|
@ -0,0 +1,320 @@
|
|||
use crate::{
|
||||
check_is_apub_id_valid,
|
||||
extensions::signatures::sign_and_send,
|
||||
insert_activity,
|
||||
ActorType,
|
||||
APUB_JSON_CONTENT_TYPE,
|
||||
};
|
||||
use activitystreams::{
|
||||
base::{BaseExt, Extends, ExtendsExt},
|
||||
object::AsObject,
|
||||
};
|
||||
use anyhow::{anyhow, Context, Error};
|
||||
use background_jobs::{
|
||||
create_server,
|
||||
memory_storage::Storage,
|
||||
ActixJob,
|
||||
Backoff,
|
||||
MaxRetries,
|
||||
QueueHandle,
|
||||
WorkerConfig,
|
||||
};
|
||||
use itertools::Itertools;
|
||||
use lemmy_db_queries::DbPool;
|
||||
use lemmy_db_schema::source::{community::Community, person::Person};
|
||||
use lemmy_utils::{location_info, settings::structs::Settings, LemmyError};
|
||||
use lemmy_websocket::LemmyContext;
|
||||
use log::{debug, warn};
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{collections::BTreeMap, env, fmt::Debug, future::Future, pin::Pin};
|
||||
use url::Url;
|
||||
|
||||
/// Sends a local activity to a single, remote actor.
|
||||
///
|
||||
/// * `activity` the apub activity to be sent
|
||||
/// * `creator` the local actor which created the activity
|
||||
/// * `inbox` the inbox url where the activity should be delivered to
|
||||
pub(crate) async fn send_activity_single_dest<T, Kind>(
|
||||
activity: T,
|
||||
creator: &dyn ActorType,
|
||||
inbox: Url,
|
||||
context: &LemmyContext,
|
||||
) -> Result<(), LemmyError>
|
||||
where
|
||||
T: AsObject<Kind> + Extends<Kind> + Debug + BaseExt<Kind>,
|
||||
Kind: Serialize,
|
||||
<T as Extends<Kind>>::Error: From<serde_json::Error> + Send + Sync + 'static,
|
||||
{
|
||||
if check_is_apub_id_valid(&inbox).is_ok() {
|
||||
debug!(
|
||||
"Sending activity {:?} to {}",
|
||||
&activity.id_unchecked(),
|
||||
&inbox
|
||||
);
|
||||
send_activity_internal(
|
||||
context.activity_queue(),
|
||||
activity,
|
||||
creator,
|
||||
vec![inbox],
|
||||
context.pool(),
|
||||
true,
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// From a local community, send activity to all remote followers.
|
||||
///
|
||||
/// * `activity` the apub activity to send
|
||||
/// * `community` the sending community
|
||||
/// * `sender_shared_inbox` in case of an announce, this should be the shared inbox of the inner
|
||||
/// activities creator, as receiving a known activity will cause an error
|
||||
pub(crate) async fn send_to_community_followers<T, Kind>(
|
||||
activity: T,
|
||||
community: &Community,
|
||||
context: &LemmyContext,
|
||||
) -> Result<(), LemmyError>
|
||||
where
|
||||
T: AsObject<Kind> + Extends<Kind> + Debug + BaseExt<Kind>,
|
||||
Kind: Serialize,
|
||||
<T as Extends<Kind>>::Error: From<serde_json::Error> + Send + Sync + 'static,
|
||||
{
|
||||
let follower_inboxes: Vec<Url> = community
|
||||
.get_follower_inboxes(context.pool())
|
||||
.await?
|
||||
.iter()
|
||||
.unique()
|
||||
.filter(|inbox| inbox.host_str() != Some(&Settings::get().hostname()))
|
||||
.filter(|inbox| check_is_apub_id_valid(inbox).is_ok())
|
||||
.map(|inbox| inbox.to_owned())
|
||||
.collect();
|
||||
debug!(
|
||||
"Sending activity {:?} to followers of {}",
|
||||
&activity.id_unchecked().map(|i| i.to_string()),
|
||||
&community.actor_id
|
||||
);
|
||||
|
||||
send_activity_internal(
|
||||
context.activity_queue(),
|
||||
activity,
|
||||
community,
|
||||
follower_inboxes,
|
||||
context.pool(),
|
||||
true,
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sends an activity from a local person to a remote community.
|
||||
///
|
||||
/// * `activity` the activity to send
|
||||
/// * `creator` the creator of the activity
|
||||
/// * `community` the destination community
|
||||
///
|
||||
pub(crate) async fn send_to_community<T, Kind>(
|
||||
activity: T,
|
||||
creator: &Person,
|
||||
community: &Community,
|
||||
context: &LemmyContext,
|
||||
) -> Result<(), LemmyError>
|
||||
where
|
||||
T: AsObject<Kind> + Extends<Kind> + Debug + BaseExt<Kind>,
|
||||
Kind: Serialize,
|
||||
<T as Extends<Kind>>::Error: From<serde_json::Error> + Send + Sync + 'static,
|
||||
{
|
||||
// if this is a local community, we need to do an announce from the community instead
|
||||
if community.local {
|
||||
community
|
||||
.send_announce(activity.into_any_base()?, context)
|
||||
.await?;
|
||||
} else {
|
||||
let inbox = community.get_shared_inbox_or_inbox_url();
|
||||
check_is_apub_id_valid(&inbox)?;
|
||||
debug!(
|
||||
"Sending activity {:?} to community {}",
|
||||
&activity.id_unchecked(),
|
||||
&community.actor_id
|
||||
);
|
||||
send_activity_internal(
|
||||
context.activity_queue(),
|
||||
activity,
|
||||
creator,
|
||||
vec![inbox],
|
||||
context.pool(),
|
||||
true,
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sends notification to any persons mentioned in a comment
|
||||
///
|
||||
/// * `creator` person who created the comment
|
||||
/// * `mentions` list of inboxes of persons which are mentioned in the comment
|
||||
/// * `activity` either a `Create/Note` or `Update/Note`
|
||||
pub(crate) async fn send_comment_mentions<T, Kind>(
|
||||
creator: &Person,
|
||||
mentions: Vec<Url>,
|
||||
activity: T,
|
||||
context: &LemmyContext,
|
||||
) -> Result<(), LemmyError>
|
||||
where
|
||||
T: AsObject<Kind> + Extends<Kind> + Debug + BaseExt<Kind>,
|
||||
Kind: Serialize,
|
||||
<T as Extends<Kind>>::Error: From<serde_json::Error> + Send + Sync + 'static,
|
||||
{
|
||||
debug!(
|
||||
"Sending mentions activity {:?} to {:?}",
|
||||
&activity.id_unchecked(),
|
||||
&mentions
|
||||
);
|
||||
let mentions = mentions
|
||||
.iter()
|
||||
.filter(|inbox| check_is_apub_id_valid(inbox).is_ok())
|
||||
.map(|i| i.to_owned())
|
||||
.collect();
|
||||
send_activity_internal(
|
||||
context.activity_queue(),
|
||||
activity,
|
||||
creator,
|
||||
mentions,
|
||||
context.pool(),
|
||||
false, // Don't create a new DB row
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create new `SendActivityTasks`, which will deliver the given activity to inboxes, as well as
|
||||
/// handling signing and retrying failed deliveres.
|
||||
///
|
||||
/// The caller of this function needs to remove any blocked domains from `to`,
|
||||
/// using `check_is_apub_id_valid()`.
|
||||
async fn send_activity_internal<T, Kind>(
|
||||
activity_sender: &QueueHandle,
|
||||
activity: T,
|
||||
actor: &dyn ActorType,
|
||||
inboxes: Vec<Url>,
|
||||
pool: &DbPool,
|
||||
insert_into_db: bool,
|
||||
sensitive: bool,
|
||||
) -> Result<(), LemmyError>
|
||||
where
|
||||
T: AsObject<Kind> + Extends<Kind> + Debug,
|
||||
Kind: Serialize,
|
||||
<T as Extends<Kind>>::Error: From<serde_json::Error> + Send + Sync + 'static,
|
||||
{
|
||||
if !Settings::get().federation().enabled || inboxes.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Don't send anything to ourselves
|
||||
let hostname = Settings::get().get_hostname_without_port()?;
|
||||
let inboxes: Vec<&Url> = inboxes
|
||||
.iter()
|
||||
.filter(|i| i.domain().expect("valid inbox url") != hostname)
|
||||
.collect();
|
||||
|
||||
let activity = activity.into_any_base()?;
|
||||
let serialised_activity = serde_json::to_string(&activity)?;
|
||||
|
||||
// This is necessary because send_comment and send_comment_mentions
|
||||
// might send the same ap_id
|
||||
if insert_into_db {
|
||||
let id = activity.id().context(location_info!())?;
|
||||
insert_activity(id, activity.clone(), true, sensitive, pool).await?;
|
||||
}
|
||||
|
||||
for i in inboxes {
|
||||
let message = SendActivityTask {
|
||||
activity: serialised_activity.to_owned(),
|
||||
inbox: i.to_owned(),
|
||||
actor_id: actor.actor_id(),
|
||||
private_key: actor.private_key().context(location_info!())?,
|
||||
};
|
||||
if env::var("LEMMY_TEST_SEND_SYNC").is_ok() {
|
||||
do_send(message, &Client::default()).await?;
|
||||
} else {
|
||||
activity_sender.queue::<SendActivityTask>(message)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
struct SendActivityTask {
|
||||
activity: String,
|
||||
inbox: Url,
|
||||
actor_id: Url,
|
||||
private_key: String,
|
||||
}
|
||||
|
||||
/// Signs the activity with the sending actor's key, and delivers to the given inbox. Also retries
|
||||
/// if the delivery failed.
|
||||
impl ActixJob for SendActivityTask {
|
||||
type State = MyState;
|
||||
type Future = Pin<Box<dyn Future<Output = Result<(), Error>>>>;
|
||||
const NAME: &'static str = "SendActivityTask";
|
||||
|
||||
const MAX_RETRIES: MaxRetries = MaxRetries::Count(10);
|
||||
const BACKOFF: Backoff = Backoff::Exponential(2);
|
||||
|
||||
fn run(self, state: Self::State) -> Self::Future {
|
||||
Box::pin(async move { do_send(self, &state.client).await })
|
||||
}
|
||||
}
|
||||
|
||||
async fn do_send(task: SendActivityTask, client: &Client) -> Result<(), Error> {
|
||||
let mut headers = BTreeMap::<String, String>::new();
|
||||
headers.insert("Content-Type".into(), APUB_JSON_CONTENT_TYPE.to_string());
|
||||
let result = sign_and_send(
|
||||
client,
|
||||
headers,
|
||||
&task.inbox,
|
||||
task.activity.clone(),
|
||||
&task.actor_id,
|
||||
task.private_key.to_owned(),
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Err(e) = result {
|
||||
warn!("{}", e);
|
||||
return Err(anyhow!(
|
||||
"Failed to send activity {} to {}",
|
||||
&task.activity,
|
||||
task.inbox
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn create_activity_queue() -> QueueHandle {
|
||||
// Start the application server. This guards access to to the jobs store
|
||||
let queue_handle = create_server(Storage::new());
|
||||
|
||||
// Configure and start our workers
|
||||
WorkerConfig::new(|| MyState {
|
||||
client: Client::default(),
|
||||
})
|
||||
.register::<SendActivityTask>()
|
||||
.start(queue_handle.clone());
|
||||
|
||||
queue_handle
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct MyState {
|
||||
pub client: Client,
|
||||
}
|
17
crates/apub/src/extensions/context.rs
Normal file
17
crates/apub/src/extensions/context.rs
Normal file
|
@ -0,0 +1,17 @@
|
|||
use activitystreams::{base::AnyBase, context};
|
||||
use lemmy_utils::LemmyError;
|
||||
use serde_json::json;
|
||||
|
||||
pub(crate) fn lemmy_context() -> Result<Vec<AnyBase>, LemmyError> {
|
||||
let context_ext = AnyBase::from_arbitrary_json(json!(
|
||||
{
|
||||
"sc": "http://schema.org#",
|
||||
"sensitive": "as:sensitive",
|
||||
"stickied": "as:stickied",
|
||||
"comments_enabled": {
|
||||
"kind": "sc:Boolean",
|
||||
"id": "pt:commentsEnabled"
|
||||
}
|
||||
}))?;
|
||||
Ok(vec![AnyBase::from(context()), context_ext])
|
||||
}
|
38
crates/apub/src/extensions/group_extensions.rs
Normal file
38
crates/apub/src/extensions/group_extensions.rs
Normal file
|
@ -0,0 +1,38 @@
|
|||
use activitystreams::unparsed::UnparsedMutExt;
|
||||
use activitystreams_ext::UnparsedExtension;
|
||||
use lemmy_utils::LemmyError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Activitystreams extension to allow (de)serializing additional Community field
|
||||
/// `sensitive` (called 'nsfw' in Lemmy).
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GroupExtension {
|
||||
pub sensitive: Option<bool>,
|
||||
}
|
||||
|
||||
impl GroupExtension {
|
||||
pub fn new(sensitive: bool) -> Result<GroupExtension, LemmyError> {
|
||||
Ok(GroupExtension {
|
||||
sensitive: Some(sensitive),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<U> UnparsedExtension<U> for GroupExtension
|
||||
where
|
||||
U: UnparsedMutExt,
|
||||
{
|
||||
type Error = serde_json::Error;
|
||||
|
||||
fn try_from_unparsed(unparsed_mut: &mut U) -> Result<Self, Self::Error> {
|
||||
Ok(GroupExtension {
|
||||
sensitive: unparsed_mut.remove("sensitive")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn try_into_unparsed(self, unparsed_mut: &mut U) -> Result<(), Self::Error> {
|
||||
unparsed_mut.insert("sensitive", self.sensitive)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
4
crates/apub/src/extensions/mod.rs
Normal file
4
crates/apub/src/extensions/mod.rs
Normal file
|
@ -0,0 +1,4 @@
|
|||
pub(crate) mod context;
|
||||
pub(crate) mod group_extensions;
|
||||
pub(crate) mod page_extension;
|
||||
pub(crate) mod signatures;
|
36
crates/apub/src/extensions/page_extension.rs
Normal file
36
crates/apub/src/extensions/page_extension.rs
Normal file
|
@ -0,0 +1,36 @@
|
|||
use activitystreams::unparsed::UnparsedMutExt;
|
||||
use activitystreams_ext::UnparsedExtension;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Activitystreams extension to allow (de)serializing additional Post fields
|
||||
/// `comemnts_enabled` (called 'locked' in Lemmy),
|
||||
/// `sensitive` (called 'nsfw') and `stickied`.
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PageExtension {
|
||||
pub comments_enabled: Option<bool>,
|
||||
pub sensitive: Option<bool>,
|
||||
pub stickied: Option<bool>,
|
||||
}
|
||||
|
||||
impl<U> UnparsedExtension<U> for PageExtension
|
||||
where
|
||||
U: UnparsedMutExt,
|
||||
{
|
||||
type Error = serde_json::Error;
|
||||
|
||||
fn try_from_unparsed(unparsed_mut: &mut U) -> Result<Self, Self::Error> {
|
||||
Ok(PageExtension {
|
||||
comments_enabled: unparsed_mut.remove("commentsEnabled")?,
|
||||
sensitive: unparsed_mut.remove("sensitive")?,
|
||||
stickied: unparsed_mut.remove("stickied")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn try_into_unparsed(self, unparsed_mut: &mut U) -> Result<(), Self::Error> {
|
||||
unparsed_mut.insert("commentsEnabled", self.comments_enabled)?;
|
||||
unparsed_mut.insert("sensitive", self.sensitive)?;
|
||||
unparsed_mut.insert("stickied", self.stickied)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
139
crates/apub/src/extensions/signatures.rs
Normal file
139
crates/apub/src/extensions/signatures.rs
Normal file
|
@ -0,0 +1,139 @@
|
|||
use crate::ActorType;
|
||||
use activitystreams::unparsed::UnparsedMutExt;
|
||||
use activitystreams_ext::UnparsedExtension;
|
||||
use actix_web::HttpRequest;
|
||||
use anyhow::{anyhow, Context};
|
||||
use http::{header::HeaderName, HeaderMap, HeaderValue};
|
||||
use http_signature_normalization_actix::Config as ConfigActix;
|
||||
use http_signature_normalization_reqwest::prelude::{Config, SignExt};
|
||||
use lemmy_utils::{location_info, LemmyError};
|
||||
use log::debug;
|
||||
use openssl::{
|
||||
hash::MessageDigest,
|
||||
pkey::PKey,
|
||||
sign::{Signer, Verifier},
|
||||
};
|
||||
use reqwest::{Client, Response};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{collections::BTreeMap, str::FromStr};
|
||||
use url::Url;
|
||||
|
||||
lazy_static! {
|
||||
static ref CONFIG2: ConfigActix = ConfigActix::new();
|
||||
static ref HTTP_SIG_CONFIG: Config = Config::new();
|
||||
}
|
||||
|
||||
/// Creates an HTTP post request to `inbox_url`, with the given `client` and `headers`, and
|
||||
/// `activity` as request body. The request is signed with `private_key` and then sent.
|
||||
pub async fn sign_and_send(
|
||||
client: &Client,
|
||||
headers: BTreeMap<String, String>,
|
||||
inbox_url: &Url,
|
||||
activity: String,
|
||||
actor_id: &Url,
|
||||
private_key: String,
|
||||
) -> Result<Response, LemmyError> {
|
||||
let signing_key_id = format!("{}#main-key", actor_id);
|
||||
|
||||
let mut header_map = HeaderMap::new();
|
||||
for h in headers {
|
||||
header_map.insert(
|
||||
HeaderName::from_str(h.0.as_str())?,
|
||||
HeaderValue::from_str(h.1.as_str())?,
|
||||
);
|
||||
}
|
||||
let response = client
|
||||
.post(&inbox_url.to_string())
|
||||
.headers(header_map)
|
||||
.signature_with_digest(
|
||||
HTTP_SIG_CONFIG.clone(),
|
||||
signing_key_id,
|
||||
Sha256::new(),
|
||||
activity,
|
||||
move |signing_string| {
|
||||
let private_key = PKey::private_key_from_pem(private_key.as_bytes())?;
|
||||
let mut signer = Signer::new(MessageDigest::sha256(), &private_key)?;
|
||||
signer.update(signing_string.as_bytes())?;
|
||||
|
||||
Ok(base64::encode(signer.sign_to_vec()?)) as Result<_, LemmyError>
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Verifies the HTTP signature on an incoming inbox request.
|
||||
pub(crate) fn verify_signature(
|
||||
request: &HttpRequest,
|
||||
actor: &dyn ActorType,
|
||||
) -> Result<(), LemmyError> {
|
||||
let public_key = actor.public_key().context(location_info!())?;
|
||||
let verified = CONFIG2
|
||||
.begin_verify(
|
||||
request.method(),
|
||||
request.uri().path_and_query(),
|
||||
request.headers().clone(),
|
||||
)?
|
||||
.verify(|signature, signing_string| -> Result<bool, LemmyError> {
|
||||
debug!(
|
||||
"Verifying with key {}, message {}",
|
||||
&public_key, &signing_string
|
||||
);
|
||||
let public_key = PKey::public_key_from_pem(public_key.as_bytes())?;
|
||||
let mut verifier = Verifier::new(MessageDigest::sha256(), &public_key)?;
|
||||
verifier.update(&signing_string.as_bytes())?;
|
||||
Ok(verifier.verify(&base64::decode(signature)?)?)
|
||||
})?;
|
||||
|
||||
if verified {
|
||||
debug!("verified signature for {}", &request.uri());
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("Invalid signature on request: {}", &request.uri()).into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension for actor public key, which is needed on person and community for HTTP signatures.
|
||||
///
|
||||
/// Taken from: https://docs.rs/activitystreams/0.5.0-alpha.17/activitystreams/ext/index.html
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PublicKeyExtension {
|
||||
pub public_key: PublicKey,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PublicKey {
|
||||
pub id: String,
|
||||
pub owner: Url,
|
||||
pub public_key_pem: String,
|
||||
}
|
||||
|
||||
impl PublicKey {
|
||||
pub fn to_ext(&self) -> PublicKeyExtension {
|
||||
PublicKeyExtension {
|
||||
public_key: self.to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<U> UnparsedExtension<U> for PublicKeyExtension
|
||||
where
|
||||
U: UnparsedMutExt,
|
||||
{
|
||||
type Error = serde_json::Error;
|
||||
|
||||
fn try_from_unparsed(unparsed_mut: &mut U) -> Result<Self, Self::Error> {
|
||||
Ok(PublicKeyExtension {
|
||||
public_key: unparsed_mut.remove("publicKey")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn try_into_unparsed(self, unparsed_mut: &mut U) -> Result<(), Self::Error> {
|
||||
unparsed_mut.insert("publicKey", self.public_key)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
145
crates/apub/src/fetcher/community.rs
Normal file
145
crates/apub/src/fetcher/community.rs
Normal file
|
@ -0,0 +1,145 @@
|
|||
use crate::{
|
||||
fetcher::{
|
||||
fetch::fetch_remote_object,
|
||||
get_or_fetch_and_upsert_person,
|
||||
is_deleted,
|
||||
should_refetch_actor,
|
||||
},
|
||||
inbox::person_inbox::receive_announce,
|
||||
objects::FromApub,
|
||||
GroupExt,
|
||||
};
|
||||
use activitystreams::{
|
||||
actor::ApActorExt,
|
||||
collection::{CollectionExt, OrderedCollection},
|
||||
object::ObjectExt,
|
||||
};
|
||||
use anyhow::Context;
|
||||
use diesel::result::Error::NotFound;
|
||||
use lemmy_api_structs::blocking;
|
||||
use lemmy_db_queries::{source::community::Community_, ApubObject, Joinable};
|
||||
use lemmy_db_schema::source::community::{Community, CommunityModerator, CommunityModeratorForm};
|
||||
use lemmy_utils::{location_info, LemmyError};
|
||||
use lemmy_websocket::LemmyContext;
|
||||
use log::debug;
|
||||
use url::Url;
|
||||
|
||||
/// Get a community from its apub ID.
|
||||
///
|
||||
/// If it exists locally and `!should_refetch_actor()`, it is returned directly from the database.
|
||||
/// Otherwise it is fetched from the remote instance, stored and returned.
|
||||
pub(crate) async fn get_or_fetch_and_upsert_community(
|
||||
apub_id: &Url,
|
||||
context: &LemmyContext,
|
||||
recursion_counter: &mut i32,
|
||||
) -> Result<Community, LemmyError> {
|
||||
let apub_id_owned = apub_id.to_owned();
|
||||
let community = blocking(context.pool(), move |conn| {
|
||||
Community::read_from_apub_id(conn, &apub_id_owned.into())
|
||||
})
|
||||
.await?;
|
||||
|
||||
match community {
|
||||
Ok(c) if !c.local && should_refetch_actor(c.last_refreshed_at) => {
|
||||
debug!("Fetching and updating from remote community: {}", apub_id);
|
||||
fetch_remote_community(apub_id, context, Some(c), recursion_counter).await
|
||||
}
|
||||
Ok(c) => Ok(c),
|
||||
Err(NotFound {}) => {
|
||||
debug!("Fetching and creating remote community: {}", apub_id);
|
||||
fetch_remote_community(apub_id, context, None, recursion_counter).await
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Request a community by apub ID from a remote instance, including moderators. If `old_community`,
|
||||
/// is set, this is an update for a community which is already known locally. If not, we don't know
|
||||
/// the community yet and also pull the outbox, to get some initial posts.
|
||||
async fn fetch_remote_community(
|
||||
apub_id: &Url,
|
||||
context: &LemmyContext,
|
||||
old_community: Option<Community>,
|
||||
recursion_counter: &mut i32,
|
||||
) -> Result<Community, LemmyError> {
|
||||
let group = fetch_remote_object::<GroupExt>(context.client(), apub_id, recursion_counter).await;
|
||||
|
||||
if let Some(c) = old_community.to_owned() {
|
||||
if is_deleted(&group) {
|
||||
blocking(context.pool(), move |conn| {
|
||||
Community::update_deleted(conn, c.id, true)
|
||||
})
|
||||
.await??;
|
||||
} else if group.is_err() {
|
||||
// If fetching failed, return the existing data.
|
||||
return Ok(c);
|
||||
}
|
||||
}
|
||||
|
||||
let group = group?;
|
||||
let community =
|
||||
Community::from_apub(&group, context, apub_id.to_owned(), recursion_counter).await?;
|
||||
|
||||
// Also add the community moderators too
|
||||
let attributed_to = group.inner.attributed_to().context(location_info!())?;
|
||||
let creator_and_moderator_uris: Vec<&Url> = attributed_to
|
||||
.as_many()
|
||||
.context(location_info!())?
|
||||
.iter()
|
||||
.map(|a| a.as_xsd_any_uri().context(""))
|
||||
.collect::<Result<Vec<&Url>, anyhow::Error>>()?;
|
||||
|
||||
let mut creator_and_moderators = Vec::new();
|
||||
|
||||
for uri in creator_and_moderator_uris {
|
||||
let c_or_m = get_or_fetch_and_upsert_person(uri, context, recursion_counter).await?;
|
||||
|
||||
creator_and_moderators.push(c_or_m);
|
||||
}
|
||||
|
||||
// TODO: need to make this work to update mods of existing communities
|
||||
if old_community.is_none() {
|
||||
let community_id = community.id;
|
||||
blocking(context.pool(), move |conn| {
|
||||
for mod_ in creator_and_moderators {
|
||||
let community_moderator_form = CommunityModeratorForm {
|
||||
community_id,
|
||||
person_id: mod_.id,
|
||||
};
|
||||
|
||||
CommunityModerator::join(conn, &community_moderator_form)?;
|
||||
}
|
||||
Ok(()) as Result<(), LemmyError>
|
||||
})
|
||||
.await??;
|
||||
}
|
||||
|
||||
// only fetch outbox for new communities, otherwise this can create an infinite loop
|
||||
if old_community.is_none() {
|
||||
let outbox = group.inner.outbox()?.context(location_info!())?;
|
||||
fetch_community_outbox(context, outbox, &community, recursion_counter).await?
|
||||
}
|
||||
|
||||
Ok(community)
|
||||
}
|
||||
|
||||
async fn fetch_community_outbox(
|
||||
context: &LemmyContext,
|
||||
outbox: &Url,
|
||||
community: &Community,
|
||||
recursion_counter: &mut i32,
|
||||
) -> Result<(), LemmyError> {
|
||||
let outbox =
|
||||
fetch_remote_object::<OrderedCollection>(context.client(), outbox, recursion_counter).await?;
|
||||
let outbox_activities = outbox.items().context(location_info!())?.clone();
|
||||
let mut outbox_activities = outbox_activities.many().context(location_info!())?;
|
||||
if outbox_activities.len() > 20 {
|
||||
outbox_activities = outbox_activities[0..20].to_vec();
|
||||
}
|
||||
|
||||
for activity in outbox_activities {
|
||||
receive_announce(context, activity, community, recursion_counter).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
83
crates/apub/src/fetcher/fetch.rs
Normal file
83
crates/apub/src/fetcher/fetch.rs
Normal file
|
@ -0,0 +1,83 @@
|
|||
use crate::{check_is_apub_id_valid, APUB_JSON_CONTENT_TYPE};
|
||||
use anyhow::anyhow;
|
||||
use lemmy_utils::{request::retry, LemmyError};
|
||||
use reqwest::{Client, StatusCode};
|
||||
use serde::Deserialize;
|
||||
use std::time::Duration;
|
||||
use thiserror::Error;
|
||||
use url::Url;
|
||||
|
||||
/// Maximum number of HTTP requests allowed to handle a single incoming activity (or a single object
|
||||
/// fetch through the search).
|
||||
///
|
||||
/// A community fetch will load the outbox with up to 20 items, and fetch the creator for each item.
|
||||
/// So we are looking at a maximum of 22 requests (rounded up just to be safe).
|
||||
static MAX_REQUEST_NUMBER: i32 = 25;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub(in crate::fetcher) struct FetchError {
|
||||
pub inner: anyhow::Error,
|
||||
pub status_code: Option<StatusCode>,
|
||||
}
|
||||
|
||||
impl From<LemmyError> for FetchError {
|
||||
fn from(t: LemmyError) -> Self {
|
||||
FetchError {
|
||||
inner: t.inner,
|
||||
status_code: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for FetchError {
|
||||
fn from(t: reqwest::Error) -> Self {
|
||||
let status = t.status();
|
||||
FetchError {
|
||||
inner: t.into(),
|
||||
status_code: status,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for FetchError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
std::fmt::Display::fmt(&self, f)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch any type of ActivityPub object, handling things like HTTP headers, deserialisation,
|
||||
/// timeouts etc.
|
||||
pub(in crate::fetcher) async fn fetch_remote_object<Response>(
|
||||
client: &Client,
|
||||
url: &Url,
|
||||
recursion_counter: &mut i32,
|
||||
) -> Result<Response, FetchError>
|
||||
where
|
||||
Response: for<'de> Deserialize<'de> + std::fmt::Debug,
|
||||
{
|
||||
*recursion_counter += 1;
|
||||
if *recursion_counter > MAX_REQUEST_NUMBER {
|
||||
return Err(LemmyError::from(anyhow!("Maximum recursion depth reached")).into());
|
||||
}
|
||||
check_is_apub_id_valid(&url)?;
|
||||
|
||||
let timeout = Duration::from_secs(60);
|
||||
|
||||
let res = retry(|| {
|
||||
client
|
||||
.get(url.as_str())
|
||||
.header("Accept", APUB_JSON_CONTENT_TYPE)
|
||||
.timeout(timeout)
|
||||
.send()
|
||||
})
|
||||
.await?;
|
||||
|
||||
if res.status() == StatusCode::GONE {
|
||||
return Err(FetchError {
|
||||
inner: anyhow!("Remote object {} was deleted", url),
|
||||
status_code: Some(res.status()),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(res.json().await?)
|
||||
}
|
72
crates/apub/src/fetcher/mod.rs
Normal file
72
crates/apub/src/fetcher/mod.rs
Normal file
|
@ -0,0 +1,72 @@
|
|||
pub(crate) mod community;
|
||||
mod fetch;
|
||||
pub(crate) mod objects;
|
||||
pub(crate) mod person;
|
||||
pub mod search;
|
||||
|
||||
use crate::{
|
||||
fetcher::{
|
||||
community::get_or_fetch_and_upsert_community,
|
||||
fetch::FetchError,
|
||||
person::get_or_fetch_and_upsert_person,
|
||||
},
|
||||
ActorType,
|
||||
};
|
||||
use chrono::NaiveDateTime;
|
||||
use http::StatusCode;
|
||||
use lemmy_db_schema::naive_now;
|
||||
use lemmy_utils::LemmyError;
|
||||
use lemmy_websocket::LemmyContext;
|
||||
use serde::Deserialize;
|
||||
use url::Url;
|
||||
|
||||
static ACTOR_REFETCH_INTERVAL_SECONDS: i64 = 24 * 60 * 60;
|
||||
static ACTOR_REFETCH_INTERVAL_SECONDS_DEBUG: i64 = 10;
|
||||
|
||||
fn is_deleted<Response>(fetch_response: &Result<Response, FetchError>) -> bool
|
||||
where
|
||||
Response: for<'de> Deserialize<'de>,
|
||||
{
|
||||
if let Err(e) = fetch_response {
|
||||
if let Some(status) = e.status_code {
|
||||
if status == StatusCode::GONE {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Get a remote actor from its apub ID (either a person or a community). Thin wrapper around
|
||||
/// `get_or_fetch_and_upsert_person()` and `get_or_fetch_and_upsert_community()`.
|
||||
///
|
||||
/// If it exists locally and `!should_refetch_actor()`, it is returned directly from the database.
|
||||
/// Otherwise it is fetched from the remote instance, stored and returned.
|
||||
pub(crate) async fn get_or_fetch_and_upsert_actor(
|
||||
apub_id: &Url,
|
||||
context: &LemmyContext,
|
||||
recursion_counter: &mut i32,
|
||||
) -> Result<Box<dyn ActorType>, LemmyError> {
|
||||
let community = get_or_fetch_and_upsert_community(apub_id, context, recursion_counter).await;
|
||||
let actor: Box<dyn ActorType> = match community {
|
||||
Ok(c) => Box::new(c),
|
||||
Err(_) => Box::new(get_or_fetch_and_upsert_person(apub_id, context, recursion_counter).await?),
|
||||
};
|
||||
Ok(actor)
|
||||
}
|
||||
|
||||
/// Determines when a remote actor should be refetched from its instance. In release builds, this is
|
||||
/// `ACTOR_REFETCH_INTERVAL_SECONDS` after the last refetch, in debug builds
|
||||
/// `ACTOR_REFETCH_INTERVAL_SECONDS_DEBUG`.
|
||||
///
|
||||
/// TODO it won't pick up new avatars, summaries etc until a day after.
|
||||
/// Actors need an "update" activity pushed to other servers to fix this.
|
||||
fn should_refetch_actor(last_refreshed: NaiveDateTime) -> bool {
|
||||
let update_interval = if cfg!(debug_assertions) {
|
||||
// avoid infinite loop when fetching community outbox
|
||||
chrono::Duration::seconds(ACTOR_REFETCH_INTERVAL_SECONDS_DEBUG)
|
||||
} else {
|
||||
chrono::Duration::seconds(ACTOR_REFETCH_INTERVAL_SECONDS)
|
||||
};
|
||||
last_refreshed.lt(&(naive_now() - update_interval))
|
||||
}
|
83
crates/apub/src/fetcher/objects.rs
Normal file
83
crates/apub/src/fetcher/objects.rs
Normal file
|
@ -0,0 +1,83 @@
|
|||
use crate::{fetcher::fetch::fetch_remote_object, objects::FromApub, NoteExt, PageExt};
|
||||
use anyhow::anyhow;
|
||||
use diesel::result::Error::NotFound;
|
||||
use lemmy_api_structs::blocking;
|
||||
use lemmy_db_queries::{ApubObject, Crud};
|
||||
use lemmy_db_schema::source::{comment::Comment, post::Post};
|
||||
use lemmy_utils::LemmyError;
|
||||
use lemmy_websocket::LemmyContext;
|
||||
use log::debug;
|
||||
use url::Url;
|
||||
|
||||
/// Gets a post by its apub ID. If it exists locally, it is returned directly. Otherwise it is
|
||||
/// pulled from its apub ID, inserted and returned.
|
||||
///
|
||||
/// The parent community is also pulled if necessary. Comments are not pulled.
|
||||
pub(crate) async fn get_or_fetch_and_insert_post(
|
||||
post_ap_id: &Url,
|
||||
context: &LemmyContext,
|
||||
recursion_counter: &mut i32,
|
||||
) -> Result<Post, LemmyError> {
|
||||
let post_ap_id_owned = post_ap_id.to_owned();
|
||||
let post = blocking(context.pool(), move |conn| {
|
||||
Post::read_from_apub_id(conn, &post_ap_id_owned.into())
|
||||
})
|
||||
.await?;
|
||||
|
||||
match post {
|
||||
Ok(p) => Ok(p),
|
||||
Err(NotFound {}) => {
|
||||
debug!("Fetching and creating remote post: {}", post_ap_id);
|
||||
let page =
|
||||
fetch_remote_object::<PageExt>(context.client(), post_ap_id, recursion_counter).await?;
|
||||
let post = Post::from_apub(&page, context, post_ap_id.to_owned(), recursion_counter).await?;
|
||||
|
||||
Ok(post)
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets a comment by its apub ID. If it exists locally, it is returned directly. Otherwise it is
|
||||
/// pulled from its apub ID, inserted and returned.
|
||||
///
|
||||
/// The parent community, post and comment are also pulled if necessary.
|
||||
pub(crate) async fn get_or_fetch_and_insert_comment(
|
||||
comment_ap_id: &Url,
|
||||
context: &LemmyContext,
|
||||
recursion_counter: &mut i32,
|
||||
) -> Result<Comment, LemmyError> {
|
||||
let comment_ap_id_owned = comment_ap_id.to_owned();
|
||||
let comment = blocking(context.pool(), move |conn| {
|
||||
Comment::read_from_apub_id(conn, &comment_ap_id_owned.into())
|
||||
})
|
||||
.await?;
|
||||
|
||||
match comment {
|
||||
Ok(p) => Ok(p),
|
||||
Err(NotFound {}) => {
|
||||
debug!(
|
||||
"Fetching and creating remote comment and its parents: {}",
|
||||
comment_ap_id
|
||||
);
|
||||
let comment =
|
||||
fetch_remote_object::<NoteExt>(context.client(), comment_ap_id, recursion_counter).await?;
|
||||
let comment = Comment::from_apub(
|
||||
&comment,
|
||||
context,
|
||||
comment_ap_id.to_owned(),
|
||||
recursion_counter,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let post_id = comment.post_id;
|
||||
let post = blocking(context.pool(), move |conn| Post::read(conn, post_id)).await??;
|
||||
if post.locked {
|
||||
return Err(anyhow!("Post is locked").into());
|
||||
}
|
||||
|
||||
Ok(comment)
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
73
crates/apub/src/fetcher/person.rs
Normal file
73
crates/apub/src/fetcher/person.rs
Normal file
|
@ -0,0 +1,73 @@
|
|||
use crate::{
|
||||
fetcher::{fetch::fetch_remote_object, is_deleted, should_refetch_actor},
|
||||
objects::FromApub,
|
||||
PersonExt,
|
||||
};
|
||||
use anyhow::anyhow;
|
||||
use diesel::result::Error::NotFound;
|
||||
use lemmy_api_structs::blocking;
|
||||
use lemmy_db_queries::{source::person::Person_, ApubObject};
|
||||
use lemmy_db_schema::source::person::Person;
|
||||
use lemmy_utils::LemmyError;
|
||||
use lemmy_websocket::LemmyContext;
|
||||
use log::debug;
|
||||
use url::Url;
|
||||
|
||||
/// Get a person from its apub ID.
|
||||
///
|
||||
/// If it exists locally and `!should_refetch_actor()`, it is returned directly from the database.
|
||||
/// Otherwise it is fetched from the remote instance, stored and returned.
|
||||
pub(crate) async fn get_or_fetch_and_upsert_person(
|
||||
apub_id: &Url,
|
||||
context: &LemmyContext,
|
||||
recursion_counter: &mut i32,
|
||||
) -> Result<Person, LemmyError> {
|
||||
let apub_id_owned = apub_id.to_owned();
|
||||
let person = blocking(context.pool(), move |conn| {
|
||||
Person::read_from_apub_id(conn, &apub_id_owned.into())
|
||||
})
|
||||
.await?;
|
||||
|
||||
match person {
|
||||
// If its older than a day, re-fetch it
|
||||
Ok(u) if !u.local && should_refetch_actor(u.last_refreshed_at) => {
|
||||
debug!("Fetching and updating from remote person: {}", apub_id);
|
||||
let person =
|
||||
fetch_remote_object::<PersonExt>(context.client(), apub_id, recursion_counter).await;
|
||||
|
||||
if is_deleted(&person) {
|
||||
// TODO: use Person::update_deleted() once implemented
|
||||
blocking(context.pool(), move |conn| {
|
||||
Person::delete_account(conn, u.id)
|
||||
})
|
||||
.await??;
|
||||
return Err(anyhow!("Person was deleted by remote instance").into());
|
||||
} else if person.is_err() {
|
||||
return Ok(u);
|
||||
}
|
||||
|
||||
let person =
|
||||
Person::from_apub(&person?, context, apub_id.to_owned(), recursion_counter).await?;
|
||||
|
||||
let person_id = person.id;
|
||||
blocking(context.pool(), move |conn| {
|
||||
Person::mark_as_updated(conn, person_id)
|
||||
})
|
||||
.await??;
|
||||
|
||||
Ok(person)
|
||||
}
|
||||
Ok(u) => Ok(u),
|
||||
Err(NotFound {}) => {
|
||||
debug!("Fetching and creating remote person: {}", apub_id);
|
||||
let person =
|
||||
fetch_remote_object::<PersonExt>(context.client(), apub_id, recursion_counter).await?;
|
||||
|
||||
let person =
|
||||
Person::from_apub(&person, context, apub_id.to_owned(), recursion_counter).await?;
|
||||
|
||||
Ok(person)
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
206
crates/apub/src/fetcher/search.rs
Normal file
206
crates/apub/src/fetcher/search.rs
Normal file
|
@ -0,0 +1,206 @@
|
|||
use crate::{
|
||||
fetcher::{
|
||||
fetch::fetch_remote_object,
|
||||
get_or_fetch_and_upsert_community,
|
||||
get_or_fetch_and_upsert_person,
|
||||
is_deleted,
|
||||
},
|
||||
find_object_by_id,
|
||||
objects::FromApub,
|
||||
GroupExt,
|
||||
NoteExt,
|
||||
Object,
|
||||
PageExt,
|
||||
PersonExt,
|
||||
};
|
||||
use activitystreams::base::BaseExt;
|
||||
use anyhow::{anyhow, Context};
|
||||
use lemmy_api_structs::{blocking, site::SearchResponse};
|
||||
use lemmy_db_queries::{
|
||||
source::{
|
||||
comment::Comment_,
|
||||
community::Community_,
|
||||
person::Person_,
|
||||
post::Post_,
|
||||
private_message::PrivateMessage_,
|
||||
},
|
||||
SearchType,
|
||||
};
|
||||
use lemmy_db_schema::source::{
|
||||
comment::Comment,
|
||||
community::Community,
|
||||
person::Person,
|
||||
post::Post,
|
||||
private_message::PrivateMessage,
|
||||
};
|
||||
use lemmy_db_views::{comment_view::CommentView, post_view::PostView};
|
||||
use lemmy_db_views_actor::{community_view::CommunityView, person_view::PersonViewSafe};
|
||||
use lemmy_utils::{settings::structs::Settings, LemmyError};
|
||||
use lemmy_websocket::LemmyContext;
|
||||
use log::debug;
|
||||
use url::Url;
|
||||
|
||||
/// The types of ActivityPub objects that can be fetched directly by searching for their ID.
|
||||
#[derive(serde::Deserialize, Debug)]
|
||||
#[serde(untagged)]
|
||||
enum SearchAcceptedObjects {
|
||||
Person(Box<PersonExt>),
|
||||
Group(Box<GroupExt>),
|
||||
Page(Box<PageExt>),
|
||||
Comment(Box<NoteExt>),
|
||||
}
|
||||
|
||||
/// Attempt to parse the query as URL, and fetch an ActivityPub object from it.
|
||||
///
|
||||
/// Some working examples for use with the `docker/federation/` setup:
|
||||
/// http://lemmy_alpha:8541/c/main, or !main@lemmy_alpha:8541
|
||||
/// http://lemmy_beta:8551/u/lemmy_alpha, or @lemmy_beta@lemmy_beta:8551
|
||||
/// http://lemmy_gamma:8561/post/3
|
||||
/// http://lemmy_delta:8571/comment/2
|
||||
pub async fn search_by_apub_id(
|
||||
query: &str,
|
||||
context: &LemmyContext,
|
||||
) -> Result<SearchResponse, LemmyError> {
|
||||
// Parse the shorthand query url
|
||||
let query_url = if query.contains('@') {
|
||||
debug!("Search for {}", query);
|
||||
let split = query.split('@').collect::<Vec<&str>>();
|
||||
|
||||
// Person type will look like ['', username, instance]
|
||||
// Community will look like [!community, instance]
|
||||
let (name, instance) = if split.len() == 3 {
|
||||
(format!("/u/{}", split[1]), split[2])
|
||||
} else if split.len() == 2 {
|
||||
if split[0].contains('!') {
|
||||
let split2 = split[0].split('!').collect::<Vec<&str>>();
|
||||
(format!("/c/{}", split2[1]), split[1])
|
||||
} else {
|
||||
return Err(anyhow!("Invalid search query: {}", query).into());
|
||||
}
|
||||
} else {
|
||||
return Err(anyhow!("Invalid search query: {}", query).into());
|
||||
};
|
||||
|
||||
let url = format!(
|
||||
"{}://{}{}",
|
||||
Settings::get().get_protocol_string(),
|
||||
instance,
|
||||
name
|
||||
);
|
||||
Url::parse(&url)?
|
||||
} else {
|
||||
Url::parse(&query)?
|
||||
};
|
||||
|
||||
let recursion_counter = &mut 0;
|
||||
let fetch_response =
|
||||
fetch_remote_object::<SearchAcceptedObjects>(context.client(), &query_url, recursion_counter)
|
||||
.await;
|
||||
if is_deleted(&fetch_response) {
|
||||
delete_object_locally(&query_url, context).await?;
|
||||
}
|
||||
|
||||
// Necessary because we get a stack overflow using FetchError
|
||||
let fet_res = fetch_response.map_err(|e| LemmyError::from(e.inner))?;
|
||||
build_response(fet_res, query_url, recursion_counter, context).await
|
||||
}
|
||||
|
||||
async fn build_response(
|
||||
fetch_response: SearchAcceptedObjects,
|
||||
query_url: Url,
|
||||
recursion_counter: &mut i32,
|
||||
context: &LemmyContext,
|
||||
) -> Result<SearchResponse, LemmyError> {
|
||||
let domain = query_url.domain().context("url has no domain")?;
|
||||
let mut response = SearchResponse {
|
||||
type_: SearchType::All.to_string(),
|
||||
comments: vec![],
|
||||
posts: vec![],
|
||||
communities: vec![],
|
||||
users: vec![],
|
||||
};
|
||||
|
||||
match fetch_response {
|
||||
SearchAcceptedObjects::Person(p) => {
|
||||
let person_uri = p.inner.id(domain)?.context("person has no id")?;
|
||||
|
||||
let person = get_or_fetch_and_upsert_person(&person_uri, context, recursion_counter).await?;
|
||||
|
||||
response.users = vec![
|
||||
blocking(context.pool(), move |conn| {
|
||||
PersonViewSafe::read(conn, person.id)
|
||||
})
|
||||
.await??,
|
||||
];
|
||||
}
|
||||
SearchAcceptedObjects::Group(g) => {
|
||||
let community_uri = g.inner.id(domain)?.context("group has no id")?;
|
||||
|
||||
let community =
|
||||
get_or_fetch_and_upsert_community(community_uri, context, recursion_counter).await?;
|
||||
|
||||
response.communities = vec![
|
||||
blocking(context.pool(), move |conn| {
|
||||
CommunityView::read(conn, community.id, None)
|
||||
})
|
||||
.await??,
|
||||
];
|
||||
}
|
||||
SearchAcceptedObjects::Page(p) => {
|
||||
let p = Post::from_apub(&p, context, query_url, recursion_counter).await?;
|
||||
|
||||
response.posts =
|
||||
vec![blocking(context.pool(), move |conn| PostView::read(conn, p.id, None)).await??];
|
||||
}
|
||||
SearchAcceptedObjects::Comment(c) => {
|
||||
let c = Comment::from_apub(&c, context, query_url, recursion_counter).await?;
|
||||
|
||||
response.comments = vec![
|
||||
blocking(context.pool(), move |conn| {
|
||||
CommentView::read(conn, c.id, None)
|
||||
})
|
||||
.await??,
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn delete_object_locally(query_url: &Url, context: &LemmyContext) -> Result<(), LemmyError> {
|
||||
let res = find_object_by_id(context, query_url.to_owned()).await?;
|
||||
match res {
|
||||
Object::Comment(c) => {
|
||||
blocking(context.pool(), move |conn| {
|
||||
Comment::update_deleted(conn, c.id, true)
|
||||
})
|
||||
.await??;
|
||||
}
|
||||
Object::Post(p) => {
|
||||
blocking(context.pool(), move |conn| {
|
||||
Post::update_deleted(conn, p.id, true)
|
||||
})
|
||||
.await??;
|
||||
}
|
||||
Object::Person(u) => {
|
||||
// TODO: implement update_deleted() for user, move it to ApubObject trait
|
||||
blocking(context.pool(), move |conn| {
|
||||
Person::delete_account(conn, u.id)
|
||||
})
|
||||
.await??;
|
||||
}
|
||||
Object::Community(c) => {
|
||||
blocking(context.pool(), move |conn| {
|
||||
Community::update_deleted(conn, c.id, true)
|
||||
})
|
||||
.await??;
|
||||
}
|
||||
Object::PrivateMessage(pm) => {
|
||||
blocking(context.pool(), move |conn| {
|
||||
PrivateMessage::update_deleted(conn, pm.id, true)
|
||||
})
|
||||
.await??;
|
||||
}
|
||||
}
|
||||
Err(anyhow!("Object was deleted").into())
|
||||
}
|
37
crates/apub/src/http/comment.rs
Normal file
37
crates/apub/src/http/comment.rs
Normal file
|
@ -0,0 +1,37 @@
|
|||
use crate::{
|
||||
http::{create_apub_response, create_apub_tombstone_response},
|
||||
objects::ToApub,
|
||||
};
|
||||
use actix_web::{body::Body, web, web::Path, HttpResponse};
|
||||
use diesel::result::Error::NotFound;
|
||||
use lemmy_api_structs::blocking;
|
||||
use lemmy_db_queries::Crud;
|
||||
use lemmy_db_schema::{source::comment::Comment, CommentId};
|
||||
use lemmy_utils::LemmyError;
|
||||
use lemmy_websocket::LemmyContext;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CommentQuery {
|
||||
comment_id: String,
|
||||
}
|
||||
|
||||
/// Return the ActivityPub json representation of a local comment over HTTP.
|
||||
pub async fn get_apub_comment(
|
||||
info: Path<CommentQuery>,
|
||||
context: web::Data<LemmyContext>,
|
||||
) -> Result<HttpResponse<Body>, LemmyError> {
|
||||
let id = CommentId(info.comment_id.parse::<i32>()?);
|
||||
let comment = blocking(context.pool(), move |conn| Comment::read(conn, id)).await??;
|
||||
if !comment.local {
|
||||
return Err(NotFound.into());
|
||||
}
|
||||
|
||||
if !comment.deleted {
|
||||
Ok(create_apub_response(
|
||||
&comment.to_apub(context.pool()).await?,
|
||||
))
|
||||
} else {
|
||||
Ok(create_apub_tombstone_response(&comment.to_tombstone()?))
|
||||
}
|
||||
}
|
113
crates/apub/src/http/community.rs
Normal file
113
crates/apub/src/http/community.rs
Normal file
|
@ -0,0 +1,113 @@
|
|||
use crate::{
|
||||
extensions::context::lemmy_context,
|
||||
http::{create_apub_response, create_apub_tombstone_response},
|
||||
objects::ToApub,
|
||||
ActorType,
|
||||
};
|
||||
use activitystreams::{
|
||||
base::{AnyBase, BaseExt},
|
||||
collection::{CollectionExt, OrderedCollection, UnorderedCollection},
|
||||
};
|
||||
use actix_web::{body::Body, web, HttpResponse};
|
||||
use lemmy_api_structs::blocking;
|
||||
use lemmy_db_queries::source::{activity::Activity_, community::Community_};
|
||||
use lemmy_db_schema::source::{activity::Activity, community::Community};
|
||||
use lemmy_db_views_actor::community_follower_view::CommunityFollowerView;
|
||||
use lemmy_utils::LemmyError;
|
||||
use lemmy_websocket::LemmyContext;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CommunityQuery {
|
||||
community_name: String,
|
||||
}
|
||||
|
||||
/// Return the ActivityPub json representation of a local community over HTTP.
|
||||
pub async fn get_apub_community_http(
|
||||
info: web::Path<CommunityQuery>,
|
||||
context: web::Data<LemmyContext>,
|
||||
) -> Result<HttpResponse<Body>, LemmyError> {
|
||||
let community = blocking(context.pool(), move |conn| {
|
||||
Community::read_from_name(conn, &info.community_name)
|
||||
})
|
||||
.await??;
|
||||
|
||||
if !community.deleted {
|
||||
let apub = community.to_apub(context.pool()).await?;
|
||||
|
||||
Ok(create_apub_response(&apub))
|
||||
} else {
|
||||
Ok(create_apub_tombstone_response(&community.to_tombstone()?))
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an empty followers collection, only populating the size (for privacy).
|
||||
pub async fn get_apub_community_followers(
|
||||
info: web::Path<CommunityQuery>,
|
||||
context: web::Data<LemmyContext>,
|
||||
) -> Result<HttpResponse<Body>, LemmyError> {
|
||||
let community = blocking(context.pool(), move |conn| {
|
||||
Community::read_from_name(&conn, &info.community_name)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let community_id = community.id;
|
||||
let community_followers = blocking(context.pool(), move |conn| {
|
||||
CommunityFollowerView::for_community(&conn, community_id)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let mut collection = UnorderedCollection::new();
|
||||
collection
|
||||
.set_many_contexts(lemmy_context()?)
|
||||
.set_id(community.followers_url.into())
|
||||
.set_total_items(community_followers.len() as u64);
|
||||
Ok(create_apub_response(&collection))
|
||||
}
|
||||
|
||||
/// Returns the community outbox, which is populated by a maximum of 20 posts (but no other
|
||||
/// activites like votes or comments).
|
||||
pub async fn get_apub_community_outbox(
|
||||
info: web::Path<CommunityQuery>,
|
||||
context: web::Data<LemmyContext>,
|
||||
) -> Result<HttpResponse<Body>, LemmyError> {
|
||||
let community = blocking(context.pool(), move |conn| {
|
||||
Community::read_from_name(&conn, &info.community_name)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let community_actor_id = community.actor_id.to_owned();
|
||||
let activities = blocking(context.pool(), move |conn| {
|
||||
Activity::read_community_outbox(conn, &community_actor_id)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let activities = activities
|
||||
.iter()
|
||||
.map(AnyBase::from_arbitrary_json)
|
||||
.collect::<Result<Vec<AnyBase>, serde_json::Error>>()?;
|
||||
let len = activities.len();
|
||||
let mut collection = OrderedCollection::new();
|
||||
collection
|
||||
.set_many_items(activities)
|
||||
.set_many_contexts(lemmy_context()?)
|
||||
.set_id(community.get_outbox_url()?)
|
||||
.set_total_items(len as u64);
|
||||
Ok(create_apub_response(&collection))
|
||||
}
|
||||
|
||||
pub async fn get_apub_community_inbox(
|
||||
info: web::Path<CommunityQuery>,
|
||||
context: web::Data<LemmyContext>,
|
||||
) -> Result<HttpResponse<Body>, LemmyError> {
|
||||
let community = blocking(context.pool(), move |conn| {
|
||||
Community::read_from_name(&conn, &info.community_name)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let mut collection = OrderedCollection::new();
|
||||
collection
|
||||
.set_id(format!("{}/inbox", community.actor_id).parse()?)
|
||||
.set_many_contexts(lemmy_context()?);
|
||||
Ok(create_apub_response(&collection))
|
||||
}
|
68
crates/apub/src/http/mod.rs
Normal file
68
crates/apub/src/http/mod.rs
Normal file
|
@ -0,0 +1,68 @@
|
|||
use crate::APUB_JSON_CONTENT_TYPE;
|
||||
use actix_web::{body::Body, web, HttpResponse};
|
||||
use http::StatusCode;
|
||||
use lemmy_api_structs::blocking;
|
||||
use lemmy_db_queries::source::activity::Activity_;
|
||||
use lemmy_db_schema::source::activity::Activity;
|
||||
use lemmy_utils::{settings::structs::Settings, LemmyError};
|
||||
use lemmy_websocket::LemmyContext;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
|
||||
pub mod comment;
|
||||
pub mod community;
|
||||
pub mod person;
|
||||
pub mod post;
|
||||
|
||||
/// Convert the data to json and turn it into an HTTP Response with the correct ActivityPub
|
||||
/// headers.
|
||||
fn create_apub_response<T>(data: &T) -> HttpResponse<Body>
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
HttpResponse::Ok()
|
||||
.content_type(APUB_JSON_CONTENT_TYPE)
|
||||
.json(data)
|
||||
}
|
||||
|
||||
fn create_apub_tombstone_response<T>(data: &T) -> HttpResponse<Body>
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
HttpResponse::Gone()
|
||||
.content_type(APUB_JSON_CONTENT_TYPE)
|
||||
.status(StatusCode::GONE)
|
||||
.json(data)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CommunityQuery {
|
||||
type_: String,
|
||||
id: String,
|
||||
}
|
||||
|
||||
/// Return the ActivityPub json representation of a local community over HTTP.
|
||||
pub async fn get_activity(
|
||||
info: web::Path<CommunityQuery>,
|
||||
context: web::Data<LemmyContext>,
|
||||
) -> Result<HttpResponse<Body>, LemmyError> {
|
||||
let settings = Settings::get();
|
||||
let activity_id = Url::parse(&format!(
|
||||
"{}/activities/{}/{}",
|
||||
settings.get_protocol_and_hostname(),
|
||||
info.type_,
|
||||
info.id
|
||||
))?
|
||||
.into();
|
||||
let activity = blocking(context.pool(), move |conn| {
|
||||
Activity::read_from_apub_id(&conn, &activity_id)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let sensitive = activity.sensitive.unwrap_or(true);
|
||||
if !activity.local || sensitive {
|
||||
Ok(HttpResponse::NotFound().finish())
|
||||
} else {
|
||||
Ok(create_apub_response(&activity.data))
|
||||
}
|
||||
}
|
78
crates/apub/src/http/person.rs
Normal file
78
crates/apub/src/http/person.rs
Normal file
|
@ -0,0 +1,78 @@
|
|||
use crate::{
|
||||
extensions::context::lemmy_context,
|
||||
http::{create_apub_response, create_apub_tombstone_response},
|
||||
objects::ToApub,
|
||||
ActorType,
|
||||
};
|
||||
use activitystreams::{
|
||||
base::BaseExt,
|
||||
collection::{CollectionExt, OrderedCollection},
|
||||
};
|
||||
use actix_web::{body::Body, web, HttpResponse};
|
||||
use lemmy_api_structs::blocking;
|
||||
use lemmy_db_queries::source::person::Person_;
|
||||
use lemmy_db_schema::source::person::Person;
|
||||
use lemmy_utils::LemmyError;
|
||||
use lemmy_websocket::LemmyContext;
|
||||
use serde::Deserialize;
|
||||
use url::Url;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PersonQuery {
|
||||
user_name: String,
|
||||
}
|
||||
|
||||
/// Return the ActivityPub json representation of a local person over HTTP.
|
||||
pub async fn get_apub_person_http(
|
||||
info: web::Path<PersonQuery>,
|
||||
context: web::Data<LemmyContext>,
|
||||
) -> Result<HttpResponse<Body>, LemmyError> {
|
||||
let user_name = info.into_inner().user_name;
|
||||
// TODO: this needs to be able to read deleted persons, so that it can send tombstones
|
||||
let person = blocking(context.pool(), move |conn| {
|
||||
Person::find_by_name(conn, &user_name)
|
||||
})
|
||||
.await??;
|
||||
|
||||
if !person.deleted {
|
||||
let apub = person.to_apub(context.pool()).await?;
|
||||
|
||||
Ok(create_apub_response(&apub))
|
||||
} else {
|
||||
Ok(create_apub_tombstone_response(&person.to_tombstone()?))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_apub_person_outbox(
|
||||
info: web::Path<PersonQuery>,
|
||||
context: web::Data<LemmyContext>,
|
||||
) -> Result<HttpResponse<Body>, LemmyError> {
|
||||
let person = blocking(context.pool(), move |conn| {
|
||||
Person::find_by_name(&conn, &info.user_name)
|
||||
})
|
||||
.await??;
|
||||
// TODO: populate the person outbox
|
||||
let mut collection = OrderedCollection::new();
|
||||
collection
|
||||
.set_many_items(Vec::<Url>::new())
|
||||
.set_many_contexts(lemmy_context()?)
|
||||
.set_id(person.get_outbox_url()?)
|
||||
.set_total_items(0_u64);
|
||||
Ok(create_apub_response(&collection))
|
||||
}
|
||||
|
||||
pub async fn get_apub_person_inbox(
|
||||
info: web::Path<PersonQuery>,
|
||||
context: web::Data<LemmyContext>,
|
||||
) -> Result<HttpResponse<Body>, LemmyError> {
|
||||
let person = blocking(context.pool(), move |conn| {
|
||||
Person::find_by_name(&conn, &info.user_name)
|
||||
})
|
||||
.await??;
|
||||
|
||||
let mut collection = OrderedCollection::new();
|
||||
collection
|
||||
.set_id(format!("{}/inbox", person.actor_id.into_inner()).parse()?)
|
||||
.set_many_contexts(lemmy_context()?);
|
||||
Ok(create_apub_response(&collection))
|
||||
}
|
35
crates/apub/src/http/post.rs
Normal file
35
crates/apub/src/http/post.rs
Normal file
|
@ -0,0 +1,35 @@
|
|||
use crate::{
|
||||
http::{create_apub_response, create_apub_tombstone_response},
|
||||
objects::ToApub,
|
||||
};
|
||||
use actix_web::{body::Body, web, HttpResponse};
|
||||
use diesel::result::Error::NotFound;
|
||||
use lemmy_api_structs::blocking;
|
||||
use lemmy_db_queries::Crud;
|
||||
use lemmy_db_schema::{source::post::Post, PostId};
|
||||
use lemmy_utils::LemmyError;
|
||||
use lemmy_websocket::LemmyContext;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PostQuery {
|
||||
post_id: String,
|
||||
}
|
||||
|
||||
/// Return the ActivityPub json representation of a local post over HTTP.
|
||||
pub async fn get_apub_post(
|
||||
info: web::Path<PostQuery>,
|
||||
context: web::Data<LemmyContext>,
|
||||
) -> Result<HttpResponse<Body>, LemmyError> {
|
||||
let id = PostId(info.post_id.parse::<i32>()?);
|
||||
let post = blocking(context.pool(), move |conn| Post::read(conn, id)).await??;
|
||||
if !post.local {
|
||||
return Err(NotFound.into());
|
||||
}
|
||||
|
||||
if !post.deleted {
|
||||
Ok(create_apub_response(&post.to_apub(context.pool()).await?))
|
||||
} else {
|
||||
Ok(create_apub_tombstone_response(&post.to_tombstone()?))
|
||||
}
|
||||
}
|
281
crates/apub/src/inbox/community_inbox.rs
Normal file
281
crates/apub/src/inbox/community_inbox.rs
Normal file
|
@ -0,0 +1,281 @@
|
|||
use crate::{
|
||||
activities::receive::verify_activity_domains_valid,
|
||||
inbox::{
|
||||
assert_activity_not_local,
|
||||
get_activity_id,
|
||||
get_activity_to_and_cc,
|
||||
inbox_verify_http_signature,
|
||||
is_activity_already_known,
|
||||
is_addressed_to_public,
|
||||
receive_for_community::{
|
||||
receive_create_for_community,
|
||||
receive_delete_for_community,
|
||||
receive_dislike_for_community,
|
||||
receive_like_for_community,
|
||||
receive_undo_for_community,
|
||||
receive_update_for_community,
|
||||
},
|
||||
},
|
||||
insert_activity,
|
||||
ActorType,
|
||||
};
|
||||
use activitystreams::{
|
||||
activity::{kind::FollowType, ActorAndObject, Follow, Undo},
|
||||
base::AnyBase,
|
||||
prelude::*,
|
||||
};
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use anyhow::{anyhow, Context};
|
||||
use lemmy_api_structs::blocking;
|
||||
use lemmy_db_queries::{source::community::Community_, ApubObject, DbPool, Followable};
|
||||
use lemmy_db_schema::{
|
||||
source::{
|
||||
community::{Community, CommunityFollower, CommunityFollowerForm},
|
||||
person::Person,
|
||||
},
|
||||
CommunityId,
|
||||
};
|
||||
use lemmy_db_views_actor::community_person_ban_view::CommunityPersonBanView;
|
||||
use lemmy_utils::{location_info, LemmyError};
|
||||
use lemmy_websocket::LemmyContext;
|
||||
use log::info;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt::Debug;
|
||||
use url::Url;
|
||||
|
||||
/// Allowed activities for community inbox.
|
||||
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum CommunityValidTypes {
|
||||
Follow, // follow request from a person
|
||||
Undo, // unfollow from a person
|
||||
Create, // create post or comment
|
||||
Update, // update post or comment
|
||||
Like, // upvote post or comment
|
||||
Dislike, // downvote post or comment
|
||||
Delete, // post or comment deleted by creator
|
||||
Remove, // post or comment removed by mod or admin
|
||||
}
|
||||
|
||||
pub type CommunityAcceptedActivities = ActorAndObject<CommunityValidTypes>;
|
||||
|
||||
/// Handler for all incoming receive to community inboxes.
|
||||
pub async fn community_inbox(
|
||||
request: HttpRequest,
|
||||
input: web::Json<CommunityAcceptedActivities>,
|
||||
path: web::Path<String>,
|
||||
context: web::Data<LemmyContext>,
|
||||
) -> Result<HttpResponse, LemmyError> {
|
||||
let activity = input.into_inner();
|
||||
// First of all check the http signature
|
||||
let request_counter = &mut 0;
|
||||
let actor = inbox_verify_http_signature(&activity, &context, request, request_counter).await?;
|
||||
|
||||
// Do nothing if we received the same activity before
|
||||
let activity_id = get_activity_id(&activity, &actor.actor_id())?;
|
||||
if is_activity_already_known(context.pool(), &activity_id).await? {
|
||||
return Ok(HttpResponse::Ok().finish());
|
||||
}
|
||||
|
||||
// Check if the activity is actually meant for us
|
||||
let path = path.into_inner();
|
||||
let community = blocking(&context.pool(), move |conn| {
|
||||
Community::read_from_name(&conn, &path)
|
||||
})
|
||||
.await??;
|
||||
let to_and_cc = get_activity_to_and_cc(&activity);
|
||||
if !to_and_cc.contains(&&community.actor_id()) {
|
||||
return Err(anyhow!("Activity delivered to wrong community").into());
|
||||
}
|
||||
|
||||
assert_activity_not_local(&activity)?;
|
||||
insert_activity(&activity_id, activity.clone(), false, true, context.pool()).await?;
|
||||
|
||||
info!(
|
||||
"Community {} received activity {:?} from {}",
|
||||
community.name,
|
||||
&activity.id_unchecked(),
|
||||
&actor.actor_id()
|
||||
);
|
||||
|
||||
community_receive_message(
|
||||
activity.clone(),
|
||||
community.clone(),
|
||||
actor.as_ref(),
|
||||
&context,
|
||||
request_counter,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Receives Follow, Undo/Follow, post actions, comment actions (including votes)
|
||||
pub(crate) async fn community_receive_message(
|
||||
activity: CommunityAcceptedActivities,
|
||||
to_community: Community,
|
||||
actor: &dyn ActorType,
|
||||
context: &LemmyContext,
|
||||
request_counter: &mut i32,
|
||||
) -> Result<HttpResponse, LemmyError> {
|
||||
// Only persons can send activities to the community, so we can get the actor as person
|
||||
// unconditionally.
|
||||
let actor_id = actor.actor_id();
|
||||
let person = blocking(&context.pool(), move |conn| {
|
||||
Person::read_from_apub_id(&conn, &actor_id.into())
|
||||
})
|
||||
.await??;
|
||||
check_community_or_site_ban(&person, to_community.id, context.pool()).await?;
|
||||
|
||||
let any_base = activity.clone().into_any_base()?;
|
||||
let actor_url = actor.actor_id();
|
||||
let activity_kind = activity.kind().context(location_info!())?;
|
||||
let do_announce = match activity_kind {
|
||||
CommunityValidTypes::Follow => {
|
||||
handle_follow(any_base.clone(), person, &to_community, &context).await?;
|
||||
false
|
||||
}
|
||||
CommunityValidTypes::Undo => {
|
||||
handle_undo(
|
||||
context,
|
||||
activity.clone(),
|
||||
actor_url,
|
||||
&to_community,
|
||||
request_counter,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
CommunityValidTypes::Create => {
|
||||
receive_create_for_community(context, any_base.clone(), &actor_url, request_counter).await?;
|
||||
true
|
||||
}
|
||||
CommunityValidTypes::Update => {
|
||||
receive_update_for_community(context, any_base.clone(), &actor_url, request_counter).await?;
|
||||
true
|
||||
}
|
||||
CommunityValidTypes::Like => {
|
||||
receive_like_for_community(context, any_base.clone(), &actor_url, request_counter).await?;
|
||||
true
|
||||
}
|
||||
CommunityValidTypes::Dislike => {
|
||||
receive_dislike_for_community(context, any_base.clone(), &actor_url, request_counter).await?;
|
||||
true
|
||||
}
|
||||
CommunityValidTypes::Delete => {
|
||||
receive_delete_for_community(context, any_base.clone(), &actor_url).await?;
|
||||
true
|
||||
}
|
||||
CommunityValidTypes::Remove => {
|
||||
// TODO: we dont support remote mods, so this is ignored for now
|
||||
//receive_remove_for_community(context, any_base.clone(), &person_url).await?
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if do_announce {
|
||||
// Check again that the activity is public, just to be sure
|
||||
is_addressed_to_public(&activity)?;
|
||||
to_community
|
||||
.send_announce(activity.into_any_base()?, context)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(HttpResponse::Ok().finish())
|
||||
}
|
||||
|
||||
/// Handle a follow request from a remote person, adding the person as follower and returning an
|
||||
/// Accept activity.
|
||||
async fn handle_follow(
|
||||
activity: AnyBase,
|
||||
person: Person,
|
||||
community: &Community,
|
||||
context: &LemmyContext,
|
||||
) -> Result<HttpResponse, LemmyError> {
|
||||
let follow = Follow::from_any_base(activity)?.context(location_info!())?;
|
||||
verify_activity_domains_valid(&follow, &person.actor_id(), false)?;
|
||||
|
||||
let community_follower_form = CommunityFollowerForm {
|
||||
community_id: community.id,
|
||||
person_id: person.id,
|
||||
pending: false,
|
||||
};
|
||||
|
||||
// This will fail if they're already a follower, but ignore the error.
|
||||
blocking(&context.pool(), move |conn| {
|
||||
CommunityFollower::follow(&conn, &community_follower_form).ok()
|
||||
})
|
||||
.await?;
|
||||
|
||||
community.send_accept_follow(follow, context).await?;
|
||||
|
||||
Ok(HttpResponse::Ok().finish())
|
||||
}
|
||||
|
||||
async fn handle_undo(
|
||||
context: &LemmyContext,
|
||||
activity: CommunityAcceptedActivities,
|
||||
actor_url: Url,
|
||||
to_community: &Community,
|
||||
request_counter: &mut i32,
|
||||
) -> Result<bool, LemmyError> {
|
||||
let inner_kind = activity
|
||||
.object()
|
||||
.is_single_kind(&FollowType::Follow.to_string());
|
||||
let any_base = activity.into_any_base()?;
|
||||
if inner_kind {
|
||||
handle_undo_follow(any_base, actor_url, to_community, &context).await?;
|
||||
Ok(false)
|
||||
} else {
|
||||
receive_undo_for_community(context, any_base, &actor_url, request_counter).await?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle `Undo/Follow` from a person, removing the person from followers list.
|
||||
async fn handle_undo_follow(
|
||||
activity: AnyBase,
|
||||
person_url: Url,
|
||||
community: &Community,
|
||||
context: &LemmyContext,
|
||||
) -> Result<(), LemmyError> {
|
||||
let undo = Undo::from_any_base(activity)?.context(location_info!())?;
|
||||
verify_activity_domains_valid(&undo, &person_url, true)?;
|
||||
|
||||
let object = undo.object().to_owned().one().context(location_info!())?;
|
||||
let follow = Follow::from_any_base(object)?.context(location_info!())?;
|
||||
verify_activity_domains_valid(&follow, &person_url, false)?;
|
||||
|
||||
let person = blocking(&context.pool(), move |conn| {
|
||||
Person::read_from_apub_id(&conn, &person_url.into())
|
||||
})
|
||||
.await??;
|
||||
let community_follower_form = CommunityFollowerForm {
|
||||
community_id: community.id,
|
||||
person_id: person.id,
|
||||
pending: false,
|
||||
};
|
||||
|
||||
// This will fail if they aren't a follower, but ignore the error.
|
||||
blocking(&context.pool(), move |conn| {
|
||||
CommunityFollower::unfollow(&conn, &community_follower_form).ok()
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn check_community_or_site_ban(
|
||||
person: &Person,
|
||||
community_id: CommunityId,
|
||||
pool: &DbPool,
|
||||
) -> Result<(), LemmyError> {
|
||||
if person.banned {
|
||||
return Err(anyhow!("Person is banned from site").into());
|
||||
}
|
||||
let person_id = person.id;
|
||||
let is_banned =
|
||||
move |conn: &'_ _| CommunityPersonBanView::get(conn, person_id, community_id).is_ok();
|
||||
if blocking(pool, is_banned).await? {
|
||||
return Err(anyhow!("Person is banned from community").into());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
180
crates/apub/src/inbox/mod.rs
Normal file
180
crates/apub/src/inbox/mod.rs
Normal file
|
@ -0,0 +1,180 @@
|
|||
use crate::{
|
||||
check_is_apub_id_valid,
|
||||
extensions::signatures::verify_signature,
|
||||
fetcher::get_or_fetch_and_upsert_actor,
|
||||
ActorType,
|
||||
};
|
||||
use activitystreams::{
|
||||
activity::ActorAndObjectRefExt,
|
||||
base::{AsBase, BaseExt, Extends},
|
||||
object::{AsObject, ObjectExt},
|
||||
public,
|
||||
};
|
||||
use actix_web::HttpRequest;
|
||||
use anyhow::{anyhow, Context};
|
||||
use lemmy_api_structs::blocking;
|
||||
use lemmy_db_queries::{
|
||||
source::{activity::Activity_, community::Community_},
|
||||
ApubObject,
|
||||
DbPool,
|
||||
};
|
||||
use lemmy_db_schema::source::{activity::Activity, community::Community, person::Person};
|
||||
use lemmy_utils::{location_info, settings::structs::Settings, LemmyError};
|
||||
use lemmy_websocket::LemmyContext;
|
||||
use serde::Serialize;
|
||||
use std::fmt::Debug;
|
||||
use url::Url;
|
||||
|
||||
pub mod community_inbox;
|
||||
pub mod person_inbox;
|
||||
mod receive_for_community;
|
||||
pub mod shared_inbox;
|
||||
|
||||
pub(crate) fn get_activity_id<T, Kind>(activity: &T, creator_uri: &Url) -> Result<Url, LemmyError>
|
||||
where
|
||||
T: BaseExt<Kind> + Extends<Kind> + Debug,
|
||||
Kind: Serialize,
|
||||
<T as Extends<Kind>>::Error: From<serde_json::Error> + Send + Sync + 'static,
|
||||
{
|
||||
let creator_domain = creator_uri.host_str().context(location_info!())?;
|
||||
let activity_id = activity.id(creator_domain)?;
|
||||
Ok(activity_id.context(location_info!())?.to_owned())
|
||||
}
|
||||
|
||||
pub(crate) async fn is_activity_already_known(
|
||||
pool: &DbPool,
|
||||
activity_id: &Url,
|
||||
) -> Result<bool, LemmyError> {
|
||||
let activity_id = activity_id.to_owned().into();
|
||||
let existing = blocking(pool, move |conn| {
|
||||
Activity::read_from_apub_id(&conn, &activity_id)
|
||||
})
|
||||
.await?;
|
||||
match existing {
|
||||
Ok(_) => Ok(true),
|
||||
Err(_) => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn get_activity_to_and_cc<T, Kind>(activity: &T) -> Vec<Url>
|
||||
where
|
||||
T: AsBase<Kind> + AsObject<Kind> + ActorAndObjectRefExt,
|
||||
{
|
||||
let mut to_and_cc = vec![];
|
||||
if let Some(to) = activity.to() {
|
||||
let to = to.to_owned().unwrap_to_vec();
|
||||
let mut to = to
|
||||
.iter()
|
||||
.map(|t| t.as_xsd_any_uri())
|
||||
.flatten()
|
||||
.map(|t| t.to_owned())
|
||||
.collect();
|
||||
to_and_cc.append(&mut to);
|
||||
}
|
||||
if let Some(cc) = activity.cc() {
|
||||
let cc = cc.to_owned().unwrap_to_vec();
|
||||
let mut cc = cc
|
||||
.iter()
|
||||
.map(|c| c.as_xsd_any_uri())
|
||||
.flatten()
|
||||
.map(|c| c.to_owned())
|
||||
.collect();
|
||||
to_and_cc.append(&mut cc);
|
||||
}
|
||||
to_and_cc
|
||||
}
|
||||
|
||||
pub(crate) fn is_addressed_to_public<T, Kind>(activity: &T) -> Result<(), LemmyError>
|
||||
where
|
||||
T: AsBase<Kind> + AsObject<Kind> + ActorAndObjectRefExt,
|
||||
{
|
||||
let to_and_cc = get_activity_to_and_cc(activity);
|
||||
if to_and_cc.contains(&public()) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("Activity is not addressed to public").into())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn inbox_verify_http_signature<T, Kind>(
|
||||
activity: &T,
|
||||
context: &LemmyContext,
|
||||
request: HttpRequest,
|
||||
request_counter: &mut i32,
|
||||
) -> Result<Box<dyn ActorType>, LemmyError>
|
||||
where
|
||||
T: AsObject<Kind> + ActorAndObjectRefExt + Extends<Kind> + AsBase<Kind>,
|
||||
Kind: Serialize,
|
||||
<T as Extends<Kind>>::Error: From<serde_json::Error> + Send + Sync + 'static,
|
||||
{
|
||||
let actor_id = activity
|
||||
.actor()?
|
||||
.to_owned()
|
||||
.single_xsd_any_uri()
|
||||
.context(location_info!())?;
|
||||
check_is_apub_id_valid(&actor_id)?;
|
||||
let actor = get_or_fetch_and_upsert_actor(&actor_id, &context, request_counter).await?;
|
||||
verify_signature(&request, actor.as_ref())?;
|
||||
Ok(actor)
|
||||
}
|
||||
|
||||
/// Returns true if `to_and_cc` contains at least one local user.
|
||||
pub(crate) async fn is_addressed_to_local_person(
|
||||
to_and_cc: &[Url],
|
||||
pool: &DbPool,
|
||||
) -> Result<bool, LemmyError> {
|
||||
for url in to_and_cc {
|
||||
let url = url.to_owned();
|
||||
let person = blocking(&pool, move |conn| {
|
||||
Person::read_from_apub_id(&conn, &url.into())
|
||||
})
|
||||
.await?;
|
||||
if let Ok(u) = person {
|
||||
if u.local {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// If `to_and_cc` contains the followers collection of a remote community, returns this community
|
||||
/// (like `https://example.com/c/main/followers`)
|
||||
pub(crate) async fn is_addressed_to_community_followers(
|
||||
to_and_cc: &[Url],
|
||||
pool: &DbPool,
|
||||
) -> Result<Option<Community>, LemmyError> {
|
||||
for url in to_and_cc {
|
||||
let url = url.to_owned().into();
|
||||
let community = blocking(&pool, move |conn| {
|
||||
// ignore errors here, because the current url might not actually be a followers url
|
||||
Community::read_from_followers_url(&conn, &url).ok()
|
||||
})
|
||||
.await?;
|
||||
if let Some(c) = community {
|
||||
if !c.local {
|
||||
return Ok(Some(c));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub(in crate::inbox) fn assert_activity_not_local<T, Kind>(activity: &T) -> Result<(), LemmyError>
|
||||
where
|
||||
T: BaseExt<Kind> + Debug,
|
||||
{
|
||||
let id = activity.id_unchecked().context(location_info!())?;
|
||||
let activity_domain = id.domain().context(location_info!())?;
|
||||
|
||||
if activity_domain == Settings::get().hostname() {
|
||||
return Err(
|
||||
anyhow!(
|
||||
"Error: received activity which was sent by local instance: {:?}",
|
||||
activity
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
420
crates/apub/src/inbox/person_inbox.rs
Normal file
420
crates/apub/src/inbox/person_inbox.rs
Normal file
|
@ -0,0 +1,420 @@
|
|||
use crate::{
|
||||
activities::receive::{
|
||||
comment::{receive_create_comment, receive_update_comment},
|
||||
community::{
|
||||
receive_delete_community,
|
||||
receive_remove_community,
|
||||
receive_undo_delete_community,
|
||||
receive_undo_remove_community,
|
||||
},
|
||||
private_message::{
|
||||
receive_create_private_message,
|
||||
receive_delete_private_message,
|
||||
receive_undo_delete_private_message,
|
||||
receive_update_private_message,
|
||||
},
|
||||
receive_unhandled_activity,
|
||||
verify_activity_domains_valid,
|
||||
},
|
||||
check_is_apub_id_valid,
|
||||
fetcher::community::get_or_fetch_and_upsert_community,
|
||||
inbox::{
|
||||
assert_activity_not_local,
|
||||
get_activity_id,
|
||||
get_activity_to_and_cc,
|
||||
inbox_verify_http_signature,
|
||||
is_activity_already_known,
|
||||
is_addressed_to_community_followers,
|
||||
is_addressed_to_local_person,
|
||||
is_addressed_to_public,
|
||||
receive_for_community::{
|
||||
receive_create_for_community,
|
||||
receive_delete_for_community,
|
||||
receive_dislike_for_community,
|
||||
receive_like_for_community,
|
||||
receive_remove_for_community,
|
||||
receive_undo_for_community,
|
||||
receive_update_for_community,
|
||||
},
|
||||
},
|
||||
insert_activity,
|
||||
ActorType,
|
||||
};
|
||||
use activitystreams::{
|
||||
activity::{Accept, ActorAndObject, Announce, Create, Delete, Follow, Undo, Update},
|
||||
base::AnyBase,
|
||||
prelude::*,
|
||||
};
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use anyhow::{anyhow, Context};
|
||||
use diesel::NotFound;
|
||||
use lemmy_api_structs::blocking;
|
||||
use lemmy_db_queries::{source::person::Person_, ApubObject, Followable};
|
||||
use lemmy_db_schema::source::{
|
||||
community::{Community, CommunityFollower},
|
||||
person::Person,
|
||||
private_message::PrivateMessage,
|
||||
};
|
||||
use lemmy_utils::{location_info, LemmyError};
|
||||
use lemmy_websocket::LemmyContext;
|
||||
use log::debug;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt::Debug;
|
||||
use strum_macros::EnumString;
|
||||
use url::Url;
|
||||
|
||||
/// Allowed activities for person inbox.
|
||||
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum PersonValidTypes {
|
||||
Accept, // community accepted our follow request
|
||||
Create, // create private message
|
||||
Update, // edit private message
|
||||
Delete, // private message or community deleted by creator
|
||||
Undo, // private message or community restored
|
||||
Remove, // community removed by admin
|
||||
Announce, // post, comment or vote in community
|
||||
}
|
||||
|
||||
pub type PersonAcceptedActivities = ActorAndObject<PersonValidTypes>;
|
||||
|
||||
/// Handler for all incoming activities to person inboxes.
|
||||
pub async fn person_inbox(
|
||||
request: HttpRequest,
|
||||
input: web::Json<PersonAcceptedActivities>,
|
||||
path: web::Path<String>,
|
||||
context: web::Data<LemmyContext>,
|
||||
) -> Result<HttpResponse, LemmyError> {
|
||||
let activity = input.into_inner();
|
||||
// First of all check the http signature
|
||||
let request_counter = &mut 0;
|
||||
let actor = inbox_verify_http_signature(&activity, &context, request, request_counter).await?;
|
||||
|
||||
// Do nothing if we received the same activity before
|
||||
let activity_id = get_activity_id(&activity, &actor.actor_id())?;
|
||||
if is_activity_already_known(context.pool(), &activity_id).await? {
|
||||
return Ok(HttpResponse::Ok().finish());
|
||||
}
|
||||
|
||||
// Check if the activity is actually meant for us
|
||||
let username = path.into_inner();
|
||||
let person = blocking(&context.pool(), move |conn| {
|
||||
Person::find_by_name(&conn, &username)
|
||||
})
|
||||
.await??;
|
||||
let to_and_cc = get_activity_to_and_cc(&activity);
|
||||
// TODO: we should also accept activities that are sent to community followers
|
||||
if !to_and_cc.contains(&&person.actor_id()) {
|
||||
return Err(anyhow!("Activity delivered to wrong person").into());
|
||||
}
|
||||
|
||||
assert_activity_not_local(&activity)?;
|
||||
insert_activity(&activity_id, activity.clone(), false, true, context.pool()).await?;
|
||||
|
||||
debug!(
|
||||
"Person {} received activity {:?} from {}",
|
||||
person.name,
|
||||
&activity.id_unchecked(),
|
||||
&actor.actor_id()
|
||||
);
|
||||
|
||||
person_receive_message(
|
||||
activity.clone(),
|
||||
Some(person.clone()),
|
||||
actor.as_ref(),
|
||||
&context,
|
||||
request_counter,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Receives Accept/Follow, Announce, private messages and community (undo) remove, (undo) delete
|
||||
pub(crate) async fn person_receive_message(
|
||||
activity: PersonAcceptedActivities,
|
||||
to_person: Option<Person>,
|
||||
actor: &dyn ActorType,
|
||||
context: &LemmyContext,
|
||||
request_counter: &mut i32,
|
||||
) -> Result<HttpResponse, LemmyError> {
|
||||
is_for_person_inbox(context, &activity).await?;
|
||||
|
||||
let any_base = activity.clone().into_any_base()?;
|
||||
let kind = activity.kind().context(location_info!())?;
|
||||
let actor_url = actor.actor_id();
|
||||
match kind {
|
||||
PersonValidTypes::Accept => {
|
||||
receive_accept(
|
||||
&context,
|
||||
any_base,
|
||||
actor,
|
||||
to_person.expect("person provided"),
|
||||
request_counter,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
PersonValidTypes::Announce => {
|
||||
receive_announce(&context, any_base, actor, request_counter).await?
|
||||
}
|
||||
PersonValidTypes::Create => {
|
||||
receive_create(&context, any_base, actor_url, request_counter).await?
|
||||
}
|
||||
PersonValidTypes::Update => {
|
||||
receive_update(&context, any_base, actor_url, request_counter).await?
|
||||
}
|
||||
PersonValidTypes::Delete => {
|
||||
receive_delete(context, any_base, &actor_url, request_counter).await?
|
||||
}
|
||||
PersonValidTypes::Undo => receive_undo(context, any_base, &actor_url, request_counter).await?,
|
||||
PersonValidTypes::Remove => receive_remove_community(&context, any_base, &actor_url).await?,
|
||||
};
|
||||
|
||||
// TODO: would be logical to move websocket notification code here
|
||||
|
||||
Ok(HttpResponse::Ok().finish())
|
||||
}
|
||||
|
||||
/// Returns true if the activity is addressed directly to one or more local persons, or if it is
|
||||
/// addressed to the followers collection of a remote community, and at least one local person follows
|
||||
/// it.
|
||||
async fn is_for_person_inbox(
|
||||
context: &LemmyContext,
|
||||
activity: &PersonAcceptedActivities,
|
||||
) -> Result<(), LemmyError> {
|
||||
let to_and_cc = get_activity_to_and_cc(activity);
|
||||
// Check if it is addressed directly to any local person
|
||||
if is_addressed_to_local_person(&to_and_cc, context.pool()).await? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Check if it is addressed to any followers collection of a remote community, and that the
|
||||
// community has local followers.
|
||||
let community = is_addressed_to_community_followers(&to_and_cc, context.pool()).await?;
|
||||
if let Some(c) = community {
|
||||
let community_id = c.id;
|
||||
let has_local_followers = blocking(&context.pool(), move |conn| {
|
||||
CommunityFollower::has_local_followers(conn, community_id)
|
||||
})
|
||||
.await??;
|
||||
if c.local {
|
||||
return Err(
|
||||
anyhow!("Remote activity cant be addressed to followers of local community").into(),
|
||||
);
|
||||
}
|
||||
if has_local_followers {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!("Not addressed for any local person").into())
|
||||
}
|
||||
|
||||
/// Handle accepted follows.
|
||||
async fn receive_accept(
|
||||
context: &LemmyContext,
|
||||
activity: AnyBase,
|
||||
actor: &dyn ActorType,
|
||||
person: Person,
|
||||
request_counter: &mut i32,
|
||||
) -> Result<(), LemmyError> {
|
||||
let accept = Accept::from_any_base(activity)?.context(location_info!())?;
|
||||
verify_activity_domains_valid(&accept, &actor.actor_id(), false)?;
|
||||
|
||||
let object = accept.object().to_owned().one().context(location_info!())?;
|
||||
let follow = Follow::from_any_base(object)?.context(location_info!())?;
|
||||
verify_activity_domains_valid(&follow, &person.actor_id(), false)?;
|
||||
|
||||
let community_uri = accept
|
||||
.actor()?
|
||||
.to_owned()
|
||||
.single_xsd_any_uri()
|
||||
.context(location_info!())?;
|
||||
|
||||
let community =
|
||||
get_or_fetch_and_upsert_community(&community_uri, context, request_counter).await?;
|
||||
|
||||
let community_id = community.id;
|
||||
let person_id = person.id;
|
||||
// This will throw an error if no follow was requested
|
||||
blocking(&context.pool(), move |conn| {
|
||||
CommunityFollower::follow_accepted(conn, community_id, person_id)
|
||||
})
|
||||
.await??;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(EnumString)]
|
||||
enum AnnouncableActivities {
|
||||
Create,
|
||||
Update,
|
||||
Like,
|
||||
Dislike,
|
||||
Delete,
|
||||
Remove,
|
||||
Undo,
|
||||
}
|
||||
|
||||
/// Takes an announce and passes the inner activity to the appropriate handler.
|
||||
pub async fn receive_announce(
|
||||
context: &LemmyContext,
|
||||
activity: AnyBase,
|
||||
actor: &dyn ActorType,
|
||||
request_counter: &mut i32,
|
||||
) -> Result<(), LemmyError> {
|
||||
let announce = Announce::from_any_base(activity)?.context(location_info!())?;
|
||||
verify_activity_domains_valid(&announce, &actor.actor_id(), false)?;
|
||||
is_addressed_to_public(&announce)?;
|
||||
|
||||
let kind = announce
|
||||
.object()
|
||||
.as_single_kind_str()
|
||||
.and_then(|s| s.parse().ok());
|
||||
let inner_activity = announce
|
||||
.object()
|
||||
.to_owned()
|
||||
.one()
|
||||
.context(location_info!())?;
|
||||
|
||||
let inner_id = inner_activity.id().context(location_info!())?.to_owned();
|
||||
check_is_apub_id_valid(&inner_id)?;
|
||||
if is_activity_already_known(context.pool(), &inner_id).await? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
use AnnouncableActivities::*;
|
||||
match kind {
|
||||
Some(Create) => {
|
||||
receive_create_for_community(context, inner_activity, &inner_id, request_counter).await
|
||||
}
|
||||
Some(Update) => {
|
||||
receive_update_for_community(context, inner_activity, &inner_id, request_counter).await
|
||||
}
|
||||
Some(Like) => {
|
||||
receive_like_for_community(context, inner_activity, &inner_id, request_counter).await
|
||||
}
|
||||
Some(Dislike) => {
|
||||
receive_dislike_for_community(context, inner_activity, &inner_id, request_counter).await
|
||||
}
|
||||
Some(Delete) => receive_delete_for_community(context, inner_activity, &inner_id).await,
|
||||
Some(Remove) => receive_remove_for_community(context, inner_activity, &inner_id).await,
|
||||
Some(Undo) => {
|
||||
receive_undo_for_community(context, inner_activity, &inner_id, request_counter).await
|
||||
}
|
||||
_ => receive_unhandled_activity(inner_activity),
|
||||
}
|
||||
}
|
||||
|
||||
async fn receive_create(
|
||||
context: &LemmyContext,
|
||||
activity: AnyBase,
|
||||
expected_domain: Url,
|
||||
request_counter: &mut i32,
|
||||
) -> Result<(), LemmyError> {
|
||||
let create = Create::from_any_base(activity)?.context(location_info!())?;
|
||||
verify_activity_domains_valid(&create, &expected_domain, true)?;
|
||||
if is_addressed_to_public(&create).is_ok() {
|
||||
receive_create_comment(create, context, request_counter).await
|
||||
} else {
|
||||
receive_create_private_message(&context, create, expected_domain, request_counter).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn receive_update(
|
||||
context: &LemmyContext,
|
||||
activity: AnyBase,
|
||||
expected_domain: Url,
|
||||
request_counter: &mut i32,
|
||||
) -> Result<(), LemmyError> {
|
||||
let update = Update::from_any_base(activity)?.context(location_info!())?;
|
||||
verify_activity_domains_valid(&update, &expected_domain, true)?;
|
||||
if is_addressed_to_public(&update).is_ok() {
|
||||
receive_update_comment(update, context, request_counter).await
|
||||
} else {
|
||||
receive_update_private_message(&context, update, expected_domain, request_counter).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn receive_delete(
|
||||
context: &LemmyContext,
|
||||
any_base: AnyBase,
|
||||
expected_domain: &Url,
|
||||
request_counter: &mut i32,
|
||||
) -> Result<(), LemmyError> {
|
||||
use CommunityOrPrivateMessage::*;
|
||||
|
||||
let delete = Delete::from_any_base(any_base.clone())?.context(location_info!())?;
|
||||
verify_activity_domains_valid(&delete, expected_domain, true)?;
|
||||
let object_uri = delete
|
||||
.object()
|
||||
.to_owned()
|
||||
.single_xsd_any_uri()
|
||||
.context(location_info!())?;
|
||||
|
||||
match find_community_or_private_message_by_id(context, object_uri).await? {
|
||||
Community(c) => receive_delete_community(context, c).await,
|
||||
PrivateMessage(p) => receive_delete_private_message(context, delete, p, request_counter).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn receive_undo(
|
||||
context: &LemmyContext,
|
||||
any_base: AnyBase,
|
||||
expected_domain: &Url,
|
||||
request_counter: &mut i32,
|
||||
) -> Result<(), LemmyError> {
|
||||
use CommunityOrPrivateMessage::*;
|
||||
let undo = Undo::from_any_base(any_base)?.context(location_info!())?;
|
||||
verify_activity_domains_valid(&undo, expected_domain, true)?;
|
||||
|
||||
let inner_activity = undo.object().to_owned().one().context(location_info!())?;
|
||||
let kind = inner_activity.kind_str();
|
||||
match kind {
|
||||
Some("Delete") => {
|
||||
let delete = Delete::from_any_base(inner_activity)?.context(location_info!())?;
|
||||
verify_activity_domains_valid(&delete, expected_domain, true)?;
|
||||
let object_uri = delete
|
||||
.object()
|
||||
.to_owned()
|
||||
.single_xsd_any_uri()
|
||||
.context(location_info!())?;
|
||||
match find_community_or_private_message_by_id(context, object_uri).await? {
|
||||
Community(c) => receive_undo_delete_community(context, undo, c, expected_domain).await,
|
||||
PrivateMessage(p) => {
|
||||
receive_undo_delete_private_message(context, undo, expected_domain, p, request_counter)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
Some("Remove") => receive_undo_remove_community(context, undo, expected_domain).await,
|
||||
_ => receive_unhandled_activity(undo),
|
||||
}
|
||||
}
|
||||
enum CommunityOrPrivateMessage {
|
||||
Community(Community),
|
||||
PrivateMessage(PrivateMessage),
|
||||
}
|
||||
|
||||
async fn find_community_or_private_message_by_id(
|
||||
context: &LemmyContext,
|
||||
apub_id: Url,
|
||||
) -> Result<CommunityOrPrivateMessage, LemmyError> {
|
||||
let ap_id = apub_id.to_owned();
|
||||
let community = blocking(context.pool(), move |conn| {
|
||||
Community::read_from_apub_id(conn, &ap_id.into())
|
||||
})
|
||||
.await?;
|
||||
if let Ok(c) = community {
|
||||
return Ok(CommunityOrPrivateMessage::Community(c));
|
||||
}
|
||||
|
||||
let ap_id = apub_id.to_owned();
|
||||
let private_message = blocking(context.pool(), move |conn| {
|
||||
PrivateMessage::read_from_apub_id(conn, &ap_id.into())
|
||||
})
|
||||
.await?;
|
||||
if let Ok(p) = private_message {
|
||||
return Ok(CommunityOrPrivateMessage::PrivateMessage(p));
|
||||
}
|
||||
|
||||
Err(NotFound.into())
|
||||
}
|
376
crates/apub/src/inbox/receive_for_community.rs
Normal file
376
crates/apub/src/inbox/receive_for_community.rs
Normal file
|
@ -0,0 +1,376 @@
|
|||
use crate::{
|
||||
activities::receive::{
|
||||
comment::{
|
||||
receive_create_comment,
|
||||
receive_delete_comment,
|
||||
receive_dislike_comment,
|
||||
receive_like_comment,
|
||||
receive_remove_comment,
|
||||
receive_update_comment,
|
||||
},
|
||||
comment_undo::{
|
||||
receive_undo_delete_comment,
|
||||
receive_undo_dislike_comment,
|
||||
receive_undo_like_comment,
|
||||
receive_undo_remove_comment,
|
||||
},
|
||||
post::{
|
||||
receive_create_post,
|
||||
receive_delete_post,
|
||||
receive_dislike_post,
|
||||
receive_like_post,
|
||||
receive_remove_post,
|
||||
receive_update_post,
|
||||
},
|
||||
post_undo::{
|
||||
receive_undo_delete_post,
|
||||
receive_undo_dislike_post,
|
||||
receive_undo_like_post,
|
||||
receive_undo_remove_post,
|
||||
},
|
||||
receive_unhandled_activity,
|
||||
verify_activity_domains_valid,
|
||||
},
|
||||
fetcher::objects::{get_or_fetch_and_insert_comment, get_or_fetch_and_insert_post},
|
||||
find_post_or_comment_by_id,
|
||||
inbox::is_addressed_to_public,
|
||||
PostOrComment,
|
||||
};
|
||||
use activitystreams::{
|
||||
activity::{Create, Delete, Dislike, Like, Remove, Undo, Update},
|
||||
base::AnyBase,
|
||||
prelude::*,
|
||||
};
|
||||
use anyhow::Context;
|
||||
use diesel::result::Error::NotFound;
|
||||
use lemmy_api_structs::blocking;
|
||||
use lemmy_db_queries::Crud;
|
||||
use lemmy_db_schema::source::site::Site;
|
||||
use lemmy_utils::{location_info, LemmyError};
|
||||
use lemmy_websocket::LemmyContext;
|
||||
use strum_macros::EnumString;
|
||||
use url::Url;
|
||||
|
||||
#[derive(EnumString)]
|
||||
enum PageOrNote {
|
||||
Page,
|
||||
Note,
|
||||
}
|
||||
|
||||
/// This file is for post/comment activities received by the community, and for post/comment
|
||||
/// activities announced by the community and received by the person.
|
||||
|
||||
/// A post or comment being created
|
||||
pub(in crate::inbox) async fn receive_create_for_community(
|
||||
context: &LemmyContext,
|
||||
activity: AnyBase,
|
||||
expected_domain: &Url,
|
||||
request_counter: &mut i32,
|
||||
) -> Result<(), LemmyError> {
|
||||
let create = Create::from_any_base(activity)?.context(location_info!())?;
|
||||
verify_activity_domains_valid(&create, &expected_domain, true)?;
|
||||
is_addressed_to_public(&create)?;
|
||||
|
||||
let kind = create
|
||||
.object()
|
||||
.as_single_kind_str()
|
||||
.and_then(|s| s.parse().ok());
|
||||
match kind {
|
||||
Some(PageOrNote::Page) => receive_create_post(create, context, request_counter).await,
|
||||
Some(PageOrNote::Note) => receive_create_comment(create, context, request_counter).await,
|
||||
_ => receive_unhandled_activity(create),
|
||||
}
|
||||
}
|
||||
|
||||
/// A post or comment being edited
|
||||
pub(in crate::inbox) async fn receive_update_for_community(
|
||||
context: &LemmyContext,
|
||||
activity: AnyBase,
|
||||
expected_domain: &Url,
|
||||
request_counter: &mut i32,
|
||||
) -> Result<(), LemmyError> {
|
||||
let update = Update::from_any_base(activity)?.context(location_info!())?;
|
||||
verify_activity_domains_valid(&update, &expected_domain, true)?;
|
||||
is_addressed_to_public(&update)?;
|
||||
|
||||
let kind = update
|
||||
.object()
|
||||
.as_single_kind_str()
|
||||
.and_then(|s| s.parse().ok());
|
||||
match kind {
|
||||
Some(PageOrNote::Page) => receive_update_post(update, context, request_counter).await,
|
||||
Some(PageOrNote::Note) => receive_update_comment(update, context, request_counter).await,
|
||||
_ => receive_unhandled_activity(update),
|
||||
}
|
||||
}
|
||||
|
||||
/// A post or comment being upvoted
|
||||
pub(in crate::inbox) async fn receive_like_for_community(
|
||||
context: &LemmyContext,
|
||||
activity: AnyBase,
|
||||
expected_domain: &Url,
|
||||
request_counter: &mut i32,
|
||||
) -> Result<(), LemmyError> {
|
||||
let like = Like::from_any_base(activity)?.context(location_info!())?;
|
||||
verify_activity_domains_valid(&like, &expected_domain, false)?;
|
||||
is_addressed_to_public(&like)?;
|
||||
|
||||
let object_id = like
|
||||
.object()
|
||||
.as_single_xsd_any_uri()
|
||||
.context(location_info!())?;
|
||||
match fetch_post_or_comment_by_id(&object_id, context, request_counter).await? {
|
||||
PostOrComment::Post(post) => receive_like_post(like, *post, context, request_counter).await,
|
||||
PostOrComment::Comment(comment) => {
|
||||
receive_like_comment(like, *comment, context, request_counter).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A post or comment being downvoted
|
||||
pub(in crate::inbox) async fn receive_dislike_for_community(
|
||||
context: &LemmyContext,
|
||||
activity: AnyBase,
|
||||
expected_domain: &Url,
|
||||
request_counter: &mut i32,
|
||||
) -> Result<(), LemmyError> {
|
||||
let enable_downvotes = blocking(context.pool(), move |conn| {
|
||||
Site::read(conn, 1).map(|s| s.enable_downvotes)
|
||||
})
|
||||
.await??;
|
||||
if !enable_downvotes {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let dislike = Dislike::from_any_base(activity)?.context(location_info!())?;
|
||||
verify_activity_domains_valid(&dislike, &expected_domain, false)?;
|
||||
is_addressed_to_public(&dislike)?;
|
||||
|
||||
let object_id = dislike
|
||||
.object()
|
||||
.as_single_xsd_any_uri()
|
||||
.context(location_info!())?;
|
||||
match fetch_post_or_comment_by_id(&object_id, context, request_counter).await? {
|
||||
PostOrComment::Post(post) => {
|
||||
receive_dislike_post(dislike, *post, context, request_counter).await
|
||||
}
|
||||
PostOrComment::Comment(comment) => {
|
||||
receive_dislike_comment(dislike, *comment, context, request_counter).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A post or comment being deleted by its creator
|
||||
pub(in crate::inbox) async fn receive_delete_for_community(
|
||||
context: &LemmyContext,
|
||||
activity: AnyBase,
|
||||
expected_domain: &Url,
|
||||
) -> Result<(), LemmyError> {
|
||||
let delete = Delete::from_any_base(activity)?.context(location_info!())?;
|
||||
verify_activity_domains_valid(&delete, &expected_domain, true)?;
|
||||
is_addressed_to_public(&delete)?;
|
||||
|
||||
let object = delete
|
||||
.object()
|
||||
.to_owned()
|
||||
.single_xsd_any_uri()
|
||||
.context(location_info!())?;
|
||||
|
||||
match find_post_or_comment_by_id(context, object).await {
|
||||
Ok(PostOrComment::Post(p)) => receive_delete_post(context, *p).await,
|
||||
Ok(PostOrComment::Comment(c)) => receive_delete_comment(context, *c).await,
|
||||
// if we dont have the object, no need to do anything
|
||||
Err(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A post or comment being removed by a mod/admin
|
||||
pub(in crate::inbox) async fn receive_remove_for_community(
|
||||
context: &LemmyContext,
|
||||
activity: AnyBase,
|
||||
expected_domain: &Url,
|
||||
) -> Result<(), LemmyError> {
|
||||
let remove = Remove::from_any_base(activity)?.context(location_info!())?;
|
||||
verify_activity_domains_valid(&remove, &expected_domain, false)?;
|
||||
is_addressed_to_public(&remove)?;
|
||||
|
||||
let cc = remove
|
||||
.cc()
|
||||
.map(|c| c.as_many())
|
||||
.flatten()
|
||||
.context(location_info!())?;
|
||||
let community_id = cc
|
||||
.first()
|
||||
.map(|c| c.as_xsd_any_uri())
|
||||
.flatten()
|
||||
.context(location_info!())?;
|
||||
|
||||
let object = remove
|
||||
.object()
|
||||
.to_owned()
|
||||
.single_xsd_any_uri()
|
||||
.context(location_info!())?;
|
||||
|
||||
// Ensure that remove activity comes from the same domain as the community
|
||||
remove.id(community_id.domain().context(location_info!())?)?;
|
||||
|
||||
match find_post_or_comment_by_id(context, object).await {
|
||||
Ok(PostOrComment::Post(p)) => receive_remove_post(context, remove, *p).await,
|
||||
Ok(PostOrComment::Comment(c)) => receive_remove_comment(context, remove, *c).await,
|
||||
// if we dont have the object, no need to do anything
|
||||
Err(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(EnumString)]
|
||||
enum UndoableActivities {
|
||||
Delete,
|
||||
Remove,
|
||||
Like,
|
||||
Dislike,
|
||||
}
|
||||
|
||||
/// A post/comment action being reverted (either a delete, remove, upvote or downvote)
|
||||
pub(in crate::inbox) async fn receive_undo_for_community(
|
||||
context: &LemmyContext,
|
||||
activity: AnyBase,
|
||||
expected_domain: &Url,
|
||||
request_counter: &mut i32,
|
||||
) -> Result<(), LemmyError> {
|
||||
let undo = Undo::from_any_base(activity)?.context(location_info!())?;
|
||||
verify_activity_domains_valid(&undo, &expected_domain.to_owned(), true)?;
|
||||
is_addressed_to_public(&undo)?;
|
||||
|
||||
use UndoableActivities::*;
|
||||
match undo
|
||||
.object()
|
||||
.as_single_kind_str()
|
||||
.and_then(|s| s.parse().ok())
|
||||
{
|
||||
Some(Delete) => receive_undo_delete_for_community(context, undo, expected_domain).await,
|
||||
Some(Remove) => receive_undo_remove_for_community(context, undo, expected_domain).await,
|
||||
Some(Like) => {
|
||||
receive_undo_like_for_community(context, undo, expected_domain, request_counter).await
|
||||
}
|
||||
Some(Dislike) => {
|
||||
receive_undo_dislike_for_community(context, undo, expected_domain, request_counter).await
|
||||
}
|
||||
_ => receive_unhandled_activity(undo),
|
||||
}
|
||||
}
|
||||
|
||||
/// A post or comment deletion being reverted
|
||||
pub(in crate::inbox) async fn receive_undo_delete_for_community(
|
||||
context: &LemmyContext,
|
||||
undo: Undo,
|
||||
expected_domain: &Url,
|
||||
) -> Result<(), LemmyError> {
|
||||
let delete = Delete::from_any_base(undo.object().to_owned().one().context(location_info!())?)?
|
||||
.context(location_info!())?;
|
||||
verify_activity_domains_valid(&delete, &expected_domain, true)?;
|
||||
is_addressed_to_public(&delete)?;
|
||||
|
||||
let object = delete
|
||||
.object()
|
||||
.to_owned()
|
||||
.single_xsd_any_uri()
|
||||
.context(location_info!())?;
|
||||
match find_post_or_comment_by_id(context, object).await {
|
||||
Ok(PostOrComment::Post(p)) => receive_undo_delete_post(context, *p).await,
|
||||
Ok(PostOrComment::Comment(c)) => receive_undo_delete_comment(context, *c).await,
|
||||
// if we dont have the object, no need to do anything
|
||||
Err(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A post or comment removal being reverted
|
||||
pub(in crate::inbox) async fn receive_undo_remove_for_community(
|
||||
context: &LemmyContext,
|
||||
undo: Undo,
|
||||
expected_domain: &Url,
|
||||
) -> Result<(), LemmyError> {
|
||||
let remove = Remove::from_any_base(undo.object().to_owned().one().context(location_info!())?)?
|
||||
.context(location_info!())?;
|
||||
verify_activity_domains_valid(&remove, &expected_domain, false)?;
|
||||
is_addressed_to_public(&remove)?;
|
||||
|
||||
let object = remove
|
||||
.object()
|
||||
.to_owned()
|
||||
.single_xsd_any_uri()
|
||||
.context(location_info!())?;
|
||||
match find_post_or_comment_by_id(context, object).await {
|
||||
Ok(PostOrComment::Post(p)) => receive_undo_remove_post(context, *p).await,
|
||||
Ok(PostOrComment::Comment(c)) => receive_undo_remove_comment(context, *c).await,
|
||||
// if we dont have the object, no need to do anything
|
||||
Err(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A post or comment upvote being reverted
|
||||
pub(in crate::inbox) async fn receive_undo_like_for_community(
|
||||
context: &LemmyContext,
|
||||
undo: Undo,
|
||||
expected_domain: &Url,
|
||||
request_counter: &mut i32,
|
||||
) -> Result<(), LemmyError> {
|
||||
let like = Like::from_any_base(undo.object().to_owned().one().context(location_info!())?)?
|
||||
.context(location_info!())?;
|
||||
verify_activity_domains_valid(&like, &expected_domain, false)?;
|
||||
is_addressed_to_public(&like)?;
|
||||
|
||||
let object_id = like
|
||||
.object()
|
||||
.as_single_xsd_any_uri()
|
||||
.context(location_info!())?;
|
||||
match fetch_post_or_comment_by_id(&object_id, context, request_counter).await? {
|
||||
PostOrComment::Post(post) => {
|
||||
receive_undo_like_post(&like, *post, context, request_counter).await
|
||||
}
|
||||
PostOrComment::Comment(comment) => {
|
||||
receive_undo_like_comment(&like, *comment, context, request_counter).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A post or comment downvote being reverted
|
||||
pub(in crate::inbox) async fn receive_undo_dislike_for_community(
|
||||
context: &LemmyContext,
|
||||
undo: Undo,
|
||||
expected_domain: &Url,
|
||||
request_counter: &mut i32,
|
||||
) -> Result<(), LemmyError> {
|
||||
let dislike = Dislike::from_any_base(undo.object().to_owned().one().context(location_info!())?)?
|
||||
.context(location_info!())?;
|
||||
verify_activity_domains_valid(&dislike, &expected_domain, false)?;
|
||||
is_addressed_to_public(&dislike)?;
|
||||
|
||||
let object_id = dislike
|
||||
.object()
|
||||
.as_single_xsd_any_uri()
|
||||
.context(location_info!())?;
|
||||
match fetch_post_or_comment_by_id(&object_id, context, request_counter).await? {
|
||||
PostOrComment::Post(post) => {
|
||||
receive_undo_dislike_post(&dislike, *post, context, request_counter).await
|
||||
}
|
||||
PostOrComment::Comment(comment) => {
|
||||
receive_undo_dislike_comment(&dislike, *comment, context, request_counter).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_post_or_comment_by_id(
|
||||
apub_id: &Url,
|
||||
context: &LemmyContext,
|
||||
request_counter: &mut i32,
|
||||
) -> Result<PostOrComment, LemmyError> {
|
||||
if let Ok(post) = get_or_fetch_and_insert_post(apub_id, context, request_counter).await {
|
||||
return Ok(PostOrComment::Post(Box::new(post)));
|
||||
}
|
||||
|
||||
if let Ok(comment) = get_or_fetch_and_insert_comment(apub_id, context, request_counter).await {
|
||||
return Ok(PostOrComment::Comment(Box::new(comment)));
|
||||
}
|
||||
|
||||
Err(NotFound.into())
|
||||
}
|
152
crates/apub/src/inbox/shared_inbox.rs
Normal file
152
crates/apub/src/inbox/shared_inbox.rs
Normal file
|
@ -0,0 +1,152 @@
|
|||
use crate::{
|
||||
inbox::{
|
||||
assert_activity_not_local,
|
||||
community_inbox::{community_receive_message, CommunityAcceptedActivities},
|
||||
get_activity_id,
|
||||
get_activity_to_and_cc,
|
||||
inbox_verify_http_signature,
|
||||
is_activity_already_known,
|
||||
is_addressed_to_community_followers,
|
||||
is_addressed_to_local_person,
|
||||
person_inbox::{person_receive_message, PersonAcceptedActivities},
|
||||
},
|
||||
insert_activity,
|
||||
};
|
||||
use activitystreams::{activity::ActorAndObject, prelude::*};
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use anyhow::Context;
|
||||
use lemmy_api_structs::blocking;
|
||||
use lemmy_db_queries::{ApubObject, DbPool};
|
||||
use lemmy_db_schema::source::community::Community;
|
||||
use lemmy_utils::{location_info, LemmyError};
|
||||
use lemmy_websocket::LemmyContext;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt::Debug;
|
||||
use url::Url;
|
||||
|
||||
/// Allowed activity types for shared inbox.
|
||||
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum ValidTypes {
|
||||
Create,
|
||||
Update,
|
||||
Like,
|
||||
Dislike,
|
||||
Delete,
|
||||
Undo,
|
||||
Remove,
|
||||
Announce,
|
||||
}
|
||||
|
||||
// TODO: this isnt entirely correct, cause some of these receive are not ActorAndObject,
|
||||
// but it still works due to the anybase conversion
|
||||
pub type AcceptedActivities = ActorAndObject<ValidTypes>;
|
||||
|
||||
/// Handler for all incoming requests to shared inbox.
|
||||
pub async fn shared_inbox(
|
||||
request: HttpRequest,
|
||||
input: web::Json<AcceptedActivities>,
|
||||
context: web::Data<LemmyContext>,
|
||||
) -> Result<HttpResponse, LemmyError> {
|
||||
let activity = input.into_inner();
|
||||
// First of all check the http signature
|
||||
let request_counter = &mut 0;
|
||||
let actor = inbox_verify_http_signature(&activity, &context, request, request_counter).await?;
|
||||
|
||||
// Do nothing if we received the same activity before
|
||||
let actor_id = actor.actor_id();
|
||||
let activity_id = get_activity_id(&activity, &actor_id)?;
|
||||
if is_activity_already_known(context.pool(), &activity_id).await? {
|
||||
return Ok(HttpResponse::Ok().finish());
|
||||
}
|
||||
|
||||
assert_activity_not_local(&activity)?;
|
||||
// Log the activity, so we avoid receiving and parsing it twice. Note that this could still happen
|
||||
// if we receive the same activity twice in very quick succession.
|
||||
insert_activity(&activity_id, activity.clone(), false, true, context.pool()).await?;
|
||||
|
||||
let activity_any_base = activity.clone().into_any_base()?;
|
||||
let mut res: Option<HttpResponse> = None;
|
||||
let to_and_cc = get_activity_to_and_cc(&activity);
|
||||
// Handle community first, so in case the sender is banned by the community, it will error out.
|
||||
// If we handled the person receive first, the activity would be inserted to the database before the
|
||||
// community could check for bans.
|
||||
// Note that an activity can be addressed to a community and to a person (or multiple persons) at the
|
||||
// same time. In this case we still only handle it once, to avoid duplicate websocket
|
||||
// notifications.
|
||||
let community = extract_local_community_from_destinations(&to_and_cc, context.pool()).await?;
|
||||
if let Some(community) = community {
|
||||
let community_activity = CommunityAcceptedActivities::from_any_base(activity_any_base.clone())?
|
||||
.context(location_info!())?;
|
||||
res = Some(
|
||||
community_receive_message(
|
||||
community_activity,
|
||||
community,
|
||||
actor.as_ref(),
|
||||
&context,
|
||||
request_counter,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
} else if is_addressed_to_local_person(&to_and_cc, context.pool()).await? {
|
||||
let person_activity = PersonAcceptedActivities::from_any_base(activity_any_base.clone())?
|
||||
.context(location_info!())?;
|
||||
// `to_person` is only used for follow activities (which we dont receive here), so no need to pass
|
||||
// it in
|
||||
person_receive_message(
|
||||
person_activity,
|
||||
None,
|
||||
actor.as_ref(),
|
||||
&context,
|
||||
request_counter,
|
||||
)
|
||||
.await?;
|
||||
} else if is_addressed_to_community_followers(&to_and_cc, context.pool())
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
let person_activity = PersonAcceptedActivities::from_any_base(activity_any_base.clone())?
|
||||
.context(location_info!())?;
|
||||
res = Some(
|
||||
person_receive_message(
|
||||
person_activity,
|
||||
None,
|
||||
actor.as_ref(),
|
||||
&context,
|
||||
request_counter,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
|
||||
// If none of those, throw an error
|
||||
if let Some(r) = res {
|
||||
Ok(r)
|
||||
} else {
|
||||
Ok(HttpResponse::NotImplemented().finish())
|
||||
}
|
||||
}
|
||||
|
||||
/// If `to_and_cc` contains the ID of a local community, return that community, otherwise return
|
||||
/// None.
|
||||
///
|
||||
/// This doesnt handle the case where an activity is addressed to multiple communities (because
|
||||
/// Lemmy doesnt generate such activities).
|
||||
async fn extract_local_community_from_destinations(
|
||||
to_and_cc: &[Url],
|
||||
pool: &DbPool,
|
||||
) -> Result<Option<Community>, LemmyError> {
|
||||
for url in to_and_cc {
|
||||
let url = url.to_owned();
|
||||
let community = blocking(&pool, move |conn| {
|
||||
Community::read_from_apub_id(&conn, &url.into())
|
||||
})
|
||||
.await?;
|
||||
if let Ok(c) = community {
|
||||
if c.local {
|
||||
return Ok(Some(c));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
379
crates/apub/src/lib.rs
Normal file
379
crates/apub/src/lib.rs
Normal file
|
@ -0,0 +1,379 @@
|
|||
#[macro_use]
|
||||
extern crate lazy_static;
|
||||
|
||||
pub mod activities;
|
||||
pub mod activity_queue;
|
||||
pub mod extensions;
|
||||
pub mod fetcher;
|
||||
pub mod http;
|
||||
pub mod inbox;
|
||||
pub mod objects;
|
||||
pub mod routes;
|
||||
|
||||
use crate::extensions::{
|
||||
group_extensions::GroupExtension,
|
||||
page_extension::PageExtension,
|
||||
signatures::{PublicKey, PublicKeyExtension},
|
||||
};
|
||||
use activitystreams::{
|
||||
activity::Follow,
|
||||
actor::{ApActor, Group, Person},
|
||||
base::AnyBase,
|
||||
object::{ApObject, Note, Page},
|
||||
};
|
||||
use activitystreams_ext::{Ext1, Ext2};
|
||||
use anyhow::{anyhow, Context};
|
||||
use diesel::NotFound;
|
||||
use lemmy_api_structs::blocking;
|
||||
use lemmy_db_queries::{source::activity::Activity_, ApubObject, DbPool};
|
||||
use lemmy_db_schema::{
|
||||
source::{
|
||||
activity::Activity,
|
||||
comment::Comment,
|
||||
community::Community,
|
||||
person::Person as DbPerson,
|
||||
post::Post,
|
||||
private_message::PrivateMessage,
|
||||
},
|
||||
DbUrl,
|
||||
};
|
||||
use lemmy_utils::{location_info, settings::structs::Settings, LemmyError};
|
||||
use lemmy_websocket::LemmyContext;
|
||||
use serde::Serialize;
|
||||
use std::net::IpAddr;
|
||||
use url::{ParseError, Url};
|
||||
|
||||
/// Activitystreams type for community
|
||||
type GroupExt = Ext2<ApActor<ApObject<Group>>, GroupExtension, PublicKeyExtension>;
|
||||
/// Activitystreams type for person
|
||||
type PersonExt = Ext1<ApActor<ApObject<Person>>, PublicKeyExtension>;
|
||||
/// Activitystreams type for post
|
||||
type PageExt = Ext1<ApObject<Page>, PageExtension>;
|
||||
type NoteExt = ApObject<Note>;
|
||||
|
||||
pub static APUB_JSON_CONTENT_TYPE: &str = "application/activity+json";
|
||||
|
||||
/// Checks if the ID is allowed for sending or receiving.
|
||||
///
|
||||
/// In particular, it checks for:
|
||||
/// - federation being enabled (if its disabled, only local URLs are allowed)
|
||||
/// - the correct scheme (either http or https)
|
||||
/// - URL being in the allowlist (if it is active)
|
||||
/// - URL not being in the blocklist (if it is active)
|
||||
///
|
||||
/// Note that only one of allowlist and blacklist can be enabled, not both.
|
||||
fn check_is_apub_id_valid(apub_id: &Url) -> Result<(), LemmyError> {
|
||||
let settings = Settings::get();
|
||||
let domain = apub_id.domain().context(location_info!())?.to_string();
|
||||
let local_instance = settings.get_hostname_without_port()?;
|
||||
|
||||
if !settings.federation().enabled {
|
||||
return if domain == local_instance {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(
|
||||
anyhow!(
|
||||
"Trying to connect with {}, but federation is disabled",
|
||||
domain
|
||||
)
|
||||
.into(),
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
let host = apub_id.host_str().context(location_info!())?;
|
||||
let host_as_ip = host.parse::<IpAddr>();
|
||||
if host == "localhost" || host_as_ip.is_ok() {
|
||||
return Err(anyhow!("invalid hostname {}: {}", host, apub_id).into());
|
||||
}
|
||||
|
||||
if apub_id.scheme() != Settings::get().get_protocol_string() {
|
||||
return Err(anyhow!("invalid apub id scheme {}: {}", apub_id.scheme(), apub_id).into());
|
||||
}
|
||||
|
||||
let allowed_instances = Settings::get().get_allowed_instances();
|
||||
let blocked_instances = Settings::get().get_blocked_instances();
|
||||
|
||||
if allowed_instances.is_none() && blocked_instances.is_none() {
|
||||
Ok(())
|
||||
} else if let Some(mut allowed) = allowed_instances {
|
||||
// need to allow this explicitly because apub receive might contain objects from our local
|
||||
// instance. split is needed to remove the port in our federation test setup.
|
||||
allowed.push(local_instance);
|
||||
|
||||
if allowed.contains(&domain) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("{} not in federation allowlist", domain).into())
|
||||
}
|
||||
} else if let Some(blocked) = blocked_instances {
|
||||
if blocked.contains(&domain) {
|
||||
Err(anyhow!("{} is in federation blocklist", domain).into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
} else {
|
||||
panic!("Invalid config, both allowed_instances and blocked_instances are specified");
|
||||
}
|
||||
}
|
||||
|
||||
/// Common functions for ActivityPub objects, which are implemented by most (but not all) objects
|
||||
/// and actors in Lemmy.
|
||||
#[async_trait::async_trait(?Send)]
|
||||
pub trait ApubObjectType {
|
||||
async fn send_create(&self, creator: &DbPerson, context: &LemmyContext)
|
||||
-> Result<(), LemmyError>;
|
||||
async fn send_update(&self, creator: &DbPerson, context: &LemmyContext)
|
||||
-> Result<(), LemmyError>;
|
||||
async fn send_delete(&self, creator: &DbPerson, context: &LemmyContext)
|
||||
-> Result<(), LemmyError>;
|
||||
async fn send_undo_delete(
|
||||
&self,
|
||||
creator: &DbPerson,
|
||||
context: &LemmyContext,
|
||||
) -> Result<(), LemmyError>;
|
||||
async fn send_remove(&self, mod_: &DbPerson, context: &LemmyContext) -> Result<(), LemmyError>;
|
||||
async fn send_undo_remove(
|
||||
&self,
|
||||
mod_: &DbPerson,
|
||||
context: &LemmyContext,
|
||||
) -> Result<(), LemmyError>;
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
pub trait ApubLikeableType {
|
||||
async fn send_like(&self, creator: &DbPerson, context: &LemmyContext) -> Result<(), LemmyError>;
|
||||
async fn send_dislike(
|
||||
&self,
|
||||
creator: &DbPerson,
|
||||
context: &LemmyContext,
|
||||
) -> Result<(), LemmyError>;
|
||||
async fn send_undo_like(
|
||||
&self,
|
||||
creator: &DbPerson,
|
||||
context: &LemmyContext,
|
||||
) -> Result<(), LemmyError>;
|
||||
}
|
||||
|
||||
/// Common methods provided by ActivityPub actors (community and person). Not all methods are
|
||||
/// implemented by all actors.
|
||||
#[async_trait::async_trait(?Send)]
|
||||
pub trait ActorType {
|
||||
fn is_local(&self) -> bool;
|
||||
fn actor_id(&self) -> Url;
|
||||
|
||||
// TODO: every actor should have a public key, so this shouldnt be an option (needs to be fixed in db)
|
||||
fn public_key(&self) -> Option<String>;
|
||||
fn private_key(&self) -> Option<String>;
|
||||
|
||||
async fn send_follow(
|
||||
&self,
|
||||
follow_actor_id: &Url,
|
||||
context: &LemmyContext,
|
||||
) -> Result<(), LemmyError>;
|
||||
async fn send_unfollow(
|
||||
&self,
|
||||
follow_actor_id: &Url,
|
||||
context: &LemmyContext,
|
||||
) -> Result<(), LemmyError>;
|
||||
|
||||
async fn send_accept_follow(
|
||||
&self,
|
||||
follow: Follow,
|
||||
context: &LemmyContext,
|
||||
) -> Result<(), LemmyError>;
|
||||
|
||||
async fn send_delete(&self, context: &LemmyContext) -> Result<(), LemmyError>;
|
||||
async fn send_undo_delete(&self, context: &LemmyContext) -> Result<(), LemmyError>;
|
||||
|
||||
async fn send_remove(&self, context: &LemmyContext) -> Result<(), LemmyError>;
|
||||
async fn send_undo_remove(&self, context: &LemmyContext) -> Result<(), LemmyError>;
|
||||
|
||||
async fn send_announce(
|
||||
&self,
|
||||
activity: AnyBase,
|
||||
context: &LemmyContext,
|
||||
) -> Result<(), LemmyError>;
|
||||
|
||||
/// For a given community, returns the inboxes of all followers.
|
||||
async fn get_follower_inboxes(&self, pool: &DbPool) -> Result<Vec<Url>, LemmyError>;
|
||||
|
||||
fn get_shared_inbox_or_inbox_url(&self) -> Url;
|
||||
|
||||
/// Outbox URL is not generally used by Lemmy, so it can be generated on the fly (but only for
|
||||
/// local actors).
|
||||
fn get_outbox_url(&self) -> Result<Url, LemmyError> {
|
||||
if !self.is_local() {
|
||||
return Err(anyhow!("get_outbox_url() called for remote actor").into());
|
||||
}
|
||||
Ok(Url::parse(&format!("{}/outbox", &self.actor_id()))?)
|
||||
}
|
||||
|
||||
fn get_public_key_ext(&self) -> Result<PublicKeyExtension, LemmyError> {
|
||||
Ok(
|
||||
PublicKey {
|
||||
id: format!("{}#main-key", self.actor_id()),
|
||||
owner: self.actor_id(),
|
||||
public_key_pem: self.public_key().context(location_info!())?,
|
||||
}
|
||||
.to_ext(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub enum EndpointType {
|
||||
Community,
|
||||
Person,
|
||||
Post,
|
||||
Comment,
|
||||
PrivateMessage,
|
||||
}
|
||||
|
||||
/// Generates the ActivityPub ID for a given object type and ID.
|
||||
pub fn generate_apub_endpoint(
|
||||
endpoint_type: EndpointType,
|
||||
name: &str,
|
||||
) -> Result<DbUrl, ParseError> {
|
||||
let point = match endpoint_type {
|
||||
EndpointType::Community => "c",
|
||||
EndpointType::Person => "u",
|
||||
EndpointType::Post => "post",
|
||||
EndpointType::Comment => "comment",
|
||||
EndpointType::PrivateMessage => "private_message",
|
||||
};
|
||||
|
||||
Ok(
|
||||
Url::parse(&format!(
|
||||
"{}/{}/{}",
|
||||
Settings::get().get_protocol_and_hostname(),
|
||||
point,
|
||||
name
|
||||
))?
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn generate_followers_url(actor_id: &DbUrl) -> Result<DbUrl, ParseError> {
|
||||
Ok(Url::parse(&format!("{}/followers", actor_id))?.into())
|
||||
}
|
||||
|
||||
pub fn generate_inbox_url(actor_id: &DbUrl) -> Result<DbUrl, ParseError> {
|
||||
Ok(Url::parse(&format!("{}/inbox", actor_id))?.into())
|
||||
}
|
||||
|
||||
pub fn generate_shared_inbox_url(actor_id: &DbUrl) -> Result<DbUrl, LemmyError> {
|
||||
let actor_id = actor_id.clone().into_inner();
|
||||
let url = format!(
|
||||
"{}://{}{}/inbox",
|
||||
&actor_id.scheme(),
|
||||
&actor_id.host_str().context(location_info!())?,
|
||||
if let Some(port) = actor_id.port() {
|
||||
format!(":{}", port)
|
||||
} else {
|
||||
"".to_string()
|
||||
},
|
||||
);
|
||||
Ok(Url::parse(&url)?.into())
|
||||
}
|
||||
|
||||
/// Store a sent or received activity in the database, for logging purposes. These records are not
|
||||
/// persistent.
|
||||
pub(crate) async fn insert_activity<T>(
|
||||
ap_id: &Url,
|
||||
activity: T,
|
||||
local: bool,
|
||||
sensitive: bool,
|
||||
pool: &DbPool,
|
||||
) -> Result<(), LemmyError>
|
||||
where
|
||||
T: Serialize + std::fmt::Debug + Send + 'static,
|
||||
{
|
||||
let ap_id = ap_id.to_owned().into();
|
||||
blocking(pool, move |conn| {
|
||||
Activity::insert(conn, ap_id, &activity, local, sensitive)
|
||||
})
|
||||
.await??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) enum PostOrComment {
|
||||
Comment(Box<Comment>),
|
||||
Post(Box<Post>),
|
||||
}
|
||||
|
||||
/// Tries to find a post or comment in the local database, without any network requests.
|
||||
/// This is used to handle deletions and removals, because in case we dont have the object, we can
|
||||
/// simply ignore the activity.
|
||||
pub(crate) async fn find_post_or_comment_by_id(
|
||||
context: &LemmyContext,
|
||||
apub_id: Url,
|
||||
) -> Result<PostOrComment, LemmyError> {
|
||||
let ap_id = apub_id.clone();
|
||||
let post = blocking(context.pool(), move |conn| {
|
||||
Post::read_from_apub_id(conn, &ap_id.into())
|
||||
})
|
||||
.await?;
|
||||
if let Ok(p) = post {
|
||||
return Ok(PostOrComment::Post(Box::new(p)));
|
||||
}
|
||||
|
||||
let ap_id = apub_id.clone();
|
||||
let comment = blocking(context.pool(), move |conn| {
|
||||
Comment::read_from_apub_id(conn, &ap_id.into())
|
||||
})
|
||||
.await?;
|
||||
if let Ok(c) = comment {
|
||||
return Ok(PostOrComment::Comment(Box::new(c)));
|
||||
}
|
||||
|
||||
Err(NotFound.into())
|
||||
}
|
||||
|
||||
pub(crate) enum Object {
|
||||
Comment(Box<Comment>),
|
||||
Post(Box<Post>),
|
||||
Community(Box<Community>),
|
||||
Person(Box<DbPerson>),
|
||||
PrivateMessage(Box<PrivateMessage>),
|
||||
}
|
||||
|
||||
pub(crate) async fn find_object_by_id(
|
||||
context: &LemmyContext,
|
||||
apub_id: Url,
|
||||
) -> Result<Object, LemmyError> {
|
||||
let ap_id = apub_id.clone();
|
||||
if let Ok(pc) = find_post_or_comment_by_id(context, ap_id.to_owned()).await {
|
||||
return Ok(match pc {
|
||||
PostOrComment::Post(p) => Object::Post(Box::new(*p)),
|
||||
PostOrComment::Comment(c) => Object::Comment(Box::new(*c)),
|
||||
});
|
||||
}
|
||||
|
||||
let ap_id = apub_id.clone();
|
||||
let person = blocking(context.pool(), move |conn| {
|
||||
DbPerson::read_from_apub_id(conn, &ap_id.into())
|
||||
})
|
||||
.await?;
|
||||
if let Ok(u) = person {
|
||||
return Ok(Object::Person(Box::new(u)));
|
||||
}
|
||||
|
||||
let ap_id = apub_id.clone();
|
||||
let community = blocking(context.pool(), move |conn| {
|
||||
Community::read_from_apub_id(conn, &ap_id.into())
|
||||
})
|
||||
.await?;
|
||||
if let Ok(c) = community {
|
||||
return Ok(Object::Community(Box::new(c)));
|
||||
}
|
||||
|
||||
let private_message = blocking(context.pool(), move |conn| {
|
||||
PrivateMessage::read_from_apub_id(conn, &apub_id.into())
|
||||
})
|
||||
.await?;
|
||||
if let Ok(pm) = private_message {
|
||||
return Ok(Object::PrivateMessage(Box::new(pm)));
|
||||
}
|
||||
|
||||
Err(NotFound.into())
|
||||
}
|
187
crates/apub/src/objects/comment.rs
Normal file
187
crates/apub/src/objects/comment.rs
Normal file
|
@ -0,0 +1,187 @@
|
|||
use crate::{
|
||||
extensions::context::lemmy_context,
|
||||
fetcher::objects::{get_or_fetch_and_insert_comment, get_or_fetch_and_insert_post},
|
||||
objects::{
|
||||
check_object_domain,
|
||||
check_object_for_community_or_site_ban,
|
||||
create_tombstone,
|
||||
get_object_from_apub,
|
||||
get_or_fetch_and_upsert_person,
|
||||
get_source_markdown_value,
|
||||
set_content_and_source,
|
||||
FromApub,
|
||||
FromApubToForm,
|
||||
ToApub,
|
||||
},
|
||||
NoteExt,
|
||||
};
|
||||
use activitystreams::{
|
||||
object::{kind::NoteType, ApObject, Note, Tombstone},
|
||||
prelude::*,
|
||||
public,
|
||||
};
|
||||
use anyhow::{anyhow, Context};
|
||||
use lemmy_api_structs::blocking;
|
||||
use lemmy_db_queries::{Crud, DbPool};
|
||||
use lemmy_db_schema::{
|
||||
source::{
|
||||
comment::{Comment, CommentForm},
|
||||
person::Person,
|
||||
post::Post,
|
||||
},
|
||||
CommentId,
|
||||
};
|
||||
use lemmy_utils::{
|
||||
location_info,
|
||||
utils::{convert_datetime, remove_slurs},
|
||||
LemmyError,
|
||||
};
|
||||
use lemmy_websocket::LemmyContext;
|
||||
use url::Url;
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl ToApub for Comment {
|
||||
type ApubType = NoteExt;
|
||||
|
||||
async fn to_apub(&self, pool: &DbPool) -> Result<NoteExt, LemmyError> {
|
||||
let mut comment = ApObject::new(Note::new());
|
||||
|
||||
let creator_id = self.creator_id;
|
||||
let creator = blocking(pool, move |conn| Person::read(conn, creator_id)).await??;
|
||||
|
||||
let post_id = self.post_id;
|
||||
let post = blocking(pool, move |conn| Post::read(conn, post_id)).await??;
|
||||
|
||||
// Add a vector containing some important info to the "in_reply_to" field
|
||||
// [post_ap_id, Option(parent_comment_ap_id)]
|
||||
let mut in_reply_to_vec = vec![post.ap_id.into_inner()];
|
||||
|
||||
if let Some(parent_id) = self.parent_id {
|
||||
let parent_comment = blocking(pool, move |conn| Comment::read(conn, parent_id)).await??;
|
||||
|
||||
in_reply_to_vec.push(parent_comment.ap_id.into_inner());
|
||||
}
|
||||
|
||||
comment
|
||||
// Not needed when the Post is embedded in a collection (like for community outbox)
|
||||
.set_many_contexts(lemmy_context()?)
|
||||
.set_id(self.ap_id.to_owned().into_inner())
|
||||
.set_published(convert_datetime(self.published))
|
||||
.set_to(public())
|
||||
.set_many_in_reply_tos(in_reply_to_vec)
|
||||
.set_attributed_to(creator.actor_id.into_inner());
|
||||
|
||||
set_content_and_source(&mut comment, &self.content)?;
|
||||
|
||||
if let Some(u) = self.updated {
|
||||
comment.set_updated(convert_datetime(u));
|
||||
}
|
||||
|
||||
Ok(comment)
|
||||
}
|
||||
|
||||
fn to_tombstone(&self) -> Result<Tombstone, LemmyError> {
|
||||
create_tombstone(
|
||||
self.deleted,
|
||||
self.ap_id.to_owned().into(),
|
||||
self.updated,
|
||||
NoteType::Note,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl FromApub for Comment {
|
||||
type ApubType = NoteExt;
|
||||
|
||||
/// Converts a `Note` to `Comment`.
|
||||
///
|
||||
/// If the parent community, post and comment(s) are not known locally, these are also fetched.
|
||||
async fn from_apub(
|
||||
note: &NoteExt,
|
||||
context: &LemmyContext,
|
||||
expected_domain: Url,
|
||||
request_counter: &mut i32,
|
||||
) -> Result<Comment, LemmyError> {
|
||||
let comment: Comment =
|
||||
get_object_from_apub(note, context, expected_domain, request_counter).await?;
|
||||
|
||||
let post_id = comment.post_id;
|
||||
let post = blocking(context.pool(), move |conn| Post::read(conn, post_id)).await??;
|
||||
check_object_for_community_or_site_ban(note, post.community_id, context, request_counter)
|
||||
.await?;
|
||||
if post.locked {
|
||||
// This is not very efficient because a comment gets inserted just to be deleted right
|
||||
// afterwards, but it seems to be the easiest way to implement it.
|
||||
blocking(context.pool(), move |conn| {
|
||||
Comment::delete(conn, comment.id)
|
||||
})
|
||||
.await??;
|
||||
Err(anyhow!("Post is locked").into())
|
||||
} else {
|
||||
Ok(comment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl FromApubToForm<NoteExt> for CommentForm {
|
||||
async fn from_apub(
|
||||
note: &NoteExt,
|
||||
context: &LemmyContext,
|
||||
expected_domain: Url,
|
||||
request_counter: &mut i32,
|
||||
) -> Result<CommentForm, LemmyError> {
|
||||
let creator_actor_id = ¬e
|
||||
.attributed_to()
|
||||
.context(location_info!())?
|
||||
.as_single_xsd_any_uri()
|
||||
.context(location_info!())?;
|
||||
|
||||
let creator =
|
||||
get_or_fetch_and_upsert_person(creator_actor_id, context, request_counter).await?;
|
||||
|
||||
let mut in_reply_tos = note
|
||||
.in_reply_to()
|
||||
.as_ref()
|
||||
.context(location_info!())?
|
||||
.as_many()
|
||||
.context(location_info!())?
|
||||
.iter()
|
||||
.map(|i| i.as_xsd_any_uri().context(""));
|
||||
let post_ap_id = in_reply_tos.next().context(location_info!())??;
|
||||
|
||||
// This post, or the parent comment might not yet exist on this server yet, fetch them.
|
||||
let post = get_or_fetch_and_insert_post(&post_ap_id, context, request_counter).await?;
|
||||
|
||||
// The 2nd item, if it exists, is the parent comment apub_id
|
||||
// For deeply nested comments, FromApub automatically gets called recursively
|
||||
let parent_id: Option<CommentId> = match in_reply_tos.next() {
|
||||
Some(parent_comment_uri) => {
|
||||
let parent_comment_ap_id = &parent_comment_uri?;
|
||||
let parent_comment =
|
||||
get_or_fetch_and_insert_comment(&parent_comment_ap_id, context, request_counter).await?;
|
||||
|
||||
Some(parent_comment.id)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
let content = get_source_markdown_value(note)?.context(location_info!())?;
|
||||
let content_slurs_removed = remove_slurs(&content);
|
||||
|
||||
Ok(CommentForm {
|
||||
creator_id: creator.id,
|
||||
post_id: post.id,
|
||||
parent_id,
|
||||
content: content_slurs_removed,
|
||||
removed: None,
|
||||
read: None,
|
||||
published: note.published().map(|u| u.to_owned().naive_local()),
|
||||
updated: note.updated().map(|u| u.to_owned().naive_local()),
|
||||
deleted: None,
|
||||
ap_id: Some(check_object_domain(note, expected_domain)?),
|
||||
local: false,
|
||||
})
|
||||
}
|
||||
}
|
228
crates/apub/src/objects/community.rs
Normal file
228
crates/apub/src/objects/community.rs
Normal file
|
@ -0,0 +1,228 @@
|
|||
use crate::{
|
||||
extensions::{context::lemmy_context, group_extensions::GroupExtension},
|
||||
fetcher::person::get_or_fetch_and_upsert_person,
|
||||
objects::{
|
||||
check_object_domain,
|
||||
create_tombstone,
|
||||
get_object_from_apub,
|
||||
get_source_markdown_value,
|
||||
set_content_and_source,
|
||||
FromApub,
|
||||
FromApubToForm,
|
||||
ToApub,
|
||||
},
|
||||
ActorType,
|
||||
GroupExt,
|
||||
};
|
||||
use activitystreams::{
|
||||
actor::{kind::GroupType, ApActor, Endpoints, Group},
|
||||
base::BaseExt,
|
||||
object::{ApObject, Image, Tombstone},
|
||||
prelude::*,
|
||||
};
|
||||
use activitystreams_ext::Ext2;
|
||||
use anyhow::Context;
|
||||
use lemmy_api_structs::blocking;
|
||||
use lemmy_db_queries::DbPool;
|
||||
use lemmy_db_schema::{
|
||||
naive_now,
|
||||
source::community::{Community, CommunityForm},
|
||||
};
|
||||
use lemmy_db_views_actor::community_moderator_view::CommunityModeratorView;
|
||||
use lemmy_utils::{
|
||||
location_info,
|
||||
utils::{check_slurs, check_slurs_opt, convert_datetime},
|
||||
LemmyError,
|
||||
};
|
||||
use lemmy_websocket::LemmyContext;
|
||||
use url::Url;
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl ToApub for Community {
|
||||
type ApubType = GroupExt;
|
||||
|
||||
async fn to_apub(&self, pool: &DbPool) -> Result<GroupExt, LemmyError> {
|
||||
// The attributed to, is an ordered vector with the creator actor_ids first,
|
||||
// then the rest of the moderators
|
||||
// TODO Technically the instance admins can mod the community, but lets
|
||||
// ignore that for now
|
||||
let id = self.id;
|
||||
let moderators = blocking(pool, move |conn| {
|
||||
CommunityModeratorView::for_community(&conn, id)
|
||||
})
|
||||
.await??;
|
||||
let moderators: Vec<Url> = moderators
|
||||
.into_iter()
|
||||
.map(|m| m.moderator.actor_id.into_inner())
|
||||
.collect();
|
||||
|
||||
let mut group = ApObject::new(Group::new());
|
||||
group
|
||||
.set_many_contexts(lemmy_context()?)
|
||||
.set_id(self.actor_id.to_owned().into())
|
||||
.set_name(self.title.to_owned())
|
||||
.set_published(convert_datetime(self.published))
|
||||
.set_many_attributed_tos(moderators);
|
||||
|
||||
if let Some(u) = self.updated.to_owned() {
|
||||
group.set_updated(convert_datetime(u));
|
||||
}
|
||||
if let Some(d) = self.description.to_owned() {
|
||||
set_content_and_source(&mut group, &d)?;
|
||||
}
|
||||
|
||||
if let Some(icon_url) = &self.icon {
|
||||
let mut image = Image::new();
|
||||
image.set_url::<Url>(icon_url.to_owned().into());
|
||||
group.set_icon(image.into_any_base()?);
|
||||
}
|
||||
|
||||
if let Some(banner_url) = &self.banner {
|
||||
let mut image = Image::new();
|
||||
image.set_url::<Url>(banner_url.to_owned().into());
|
||||
group.set_image(image.into_any_base()?);
|
||||
}
|
||||
|
||||
let mut ap_actor = ApActor::new(self.inbox_url.clone().into(), group);
|
||||
ap_actor
|
||||
.set_preferred_username(self.name.to_owned())
|
||||
.set_outbox(self.get_outbox_url()?)
|
||||
.set_followers(self.followers_url.clone().into())
|
||||
.set_endpoints(Endpoints {
|
||||
shared_inbox: Some(self.get_shared_inbox_or_inbox_url()),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
Ok(Ext2::new(
|
||||
ap_actor,
|
||||
GroupExtension::new(self.nsfw)?,
|
||||
self.get_public_key_ext()?,
|
||||
))
|
||||
}
|
||||
|
||||
fn to_tombstone(&self) -> Result<Tombstone, LemmyError> {
|
||||
create_tombstone(
|
||||
self.deleted,
|
||||
self.actor_id.to_owned().into(),
|
||||
self.updated,
|
||||
GroupType::Group,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl FromApub for Community {
|
||||
type ApubType = GroupExt;
|
||||
|
||||
/// Converts a `Group` to `Community`.
|
||||
async fn from_apub(
|
||||
group: &GroupExt,
|
||||
context: &LemmyContext,
|
||||
expected_domain: Url,
|
||||
request_counter: &mut i32,
|
||||
) -> Result<Community, LemmyError> {
|
||||
get_object_from_apub(group, context, expected_domain, request_counter).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
impl FromApubToForm<GroupExt> for CommunityForm {
|
||||
async fn from_apub(
|
||||
group: &GroupExt,
|
||||
context: &LemmyContext,
|
||||
expected_domain: Url,
|
||||
request_counter: &mut i32,
|
||||
) -> Result<Self, LemmyError> {
|
||||
let creator_and_moderator_uris = group.inner.attributed_to().context(location_info!())?;
|
||||
let creator_uri = creator_and_moderator_uris
|
||||
.as_many()
|
||||
.context(location_info!())?
|
||||
.iter()
|
||||
.next()
|
||||
.context(location_info!())?
|
||||
.as_xsd_any_uri()
|
||||
.context(location_info!())?;
|
||||
|
||||
let creator = get_or_fetch_and_upsert_person(creator_uri, context, request_counter).await?;
|
||||
let name = group
|
||||
.inner
|
||||
.preferred_username()
|
||||
.context(location_info!())?
|
||||
.to_string();
|
||||
let title = group
|
||||
.inner
|
||||
.name()
|
||||
.context(location_info!())?
|
||||
.as_one()
|
||||
.context(location_info!())?
|
||||
.as_xsd_string()
|
||||
.context(location_info!())?
|
||||
.to_string();
|
||||
|
||||
let description = get_source_markdown_value(group)?;
|
||||
|
||||
check_slurs(&name)?;
|
||||
check_slurs(&title)?;
|
||||
check_slurs_opt(&description)?;
|
||||
|
||||
let icon = match group.icon() {
|
||||
Some(any_image) => Some(
|
||||
Image::from_any_base(any_image.as_one().context(location_info!())?.clone())
|
||||
.context(location_info!())?
|
||||
.context(location_info!())?
|
||||
.url()
|
||||
.context(location_info!())?
|
||||
.as_single_xsd_any_uri()
|
||||
.map(|u| u.to_owned().into()),
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
let banner = match group.image() {
|
||||
Some(any_image) => Some(
|
||||
Image::from_any_base(any_image.as_one().context(location_info!())?.clone())
|
||||
.context(location_info!())?
|
||||
.context(location_info!())?
|
||||
.url()
|
||||
.context(location_info!())?
|
||||
.as_single_xsd_any_uri()
|
||||
.map(|u| u.to_owned().into()),
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
let shared_inbox = group
|
||||
.inner
|
||||
.endpoints()?
|
||||
.map(|e| e.shared_inbox)
|
||||
.flatten()
|
||||
.map(|s| s.to_owned().into());
|
||||
|
||||
Ok(CommunityForm {
|
||||
name,
|
||||
title,
|
||||
description,
|
||||
creator_id: creator.id,
|
||||
removed: None,
|
||||
published: group.inner.published().map(|u| u.to_owned().naive_local()),
|
||||
updated: group.inner.updated().map(|u| u.to_owned().naive_local()),
|
||||
deleted: None,
|
||||
nsfw: group.ext_one.sensitive.unwrap_or(false),
|
||||
actor_id: Some(check_object_domain(group, expected_domain)?),
|
||||
local: false,
|
||||
private_key: None,
|
||||
public_key: Some(group.ext_two.to_owned().public_key.public_key_pem),
|
||||
last_refreshed_at: Some(naive_now()),
|
||||
icon,
|
||||
banner,
|
||||
followers_url: Some(
|
||||
group
|
||||
.inner
|
||||
.followers()?
|
||||
.context(location_info!())?
|
||||
.to_owned()
|
||||
.into(),
|
||||
),
|
||||
inbox_url: Some(group.inner.inbox()?.to_owned().into()),
|
||||
shared_inbox_url: Some(shared_inbox),
|
||||
})
|
||||
}
|
||||
}
|
247
crates/apub/src/objects/mod.rs
Normal file
247
crates/apub/src/objects/mod.rs
Normal file
|
@ -0,0 +1,247 @@
|
|||
use crate::{
|
||||
check_is_apub_id_valid,
|
||||
fetcher::{community::get_or_fetch_and_upsert_community, person::get_or_fetch_and_upsert_person},
|
||||
inbox::community_inbox::check_community_or_site_ban,
|
||||
};
|
||||
use activitystreams::{
|
||||
base::{AsBase, BaseExt, ExtendsExt},
|
||||
markers::Base,
|
||||
mime::{FromStrError, Mime},
|
||||
object::{ApObjectExt, Object, ObjectExt, Tombstone, TombstoneExt},
|
||||
};
|
||||
use anyhow::{anyhow, Context};
|
||||
use chrono::NaiveDateTime;
|
||||
use diesel::result::Error::NotFound;
|
||||
use lemmy_api_structs::blocking;
|
||||
use lemmy_db_queries::{ApubObject, Crud, DbPool};
|
||||
use lemmy_db_schema::{source::community::Community, CommunityId, DbUrl};
|
||||
use lemmy_utils::{
|
||||
location_info,
|
||||
settings::structs::Settings,
|
||||
utils::{convert_datetime, markdown_to_html},
|
||||
LemmyError,
|
||||
};
|
||||
use lemmy_websocket::LemmyContext;
|
||||
use url::Url;
|
||||
|
||||
pub(crate) mod comment;
|
||||
pub(crate) mod community;
|
||||
pub(crate) mod person;
|
||||
pub(crate) mod post;
|
||||
pub(crate) mod private_message;
|
||||
|
||||
/// Trait for converting an object or actor into the respective ActivityPub type.
|
||||
#[async_trait::async_trait(?Send)]
|
||||
pub(crate) trait ToApub {
|
||||
type ApubType;
|
||||
async fn to_apub(&self, pool: &DbPool) -> Result<Self::ApubType, LemmyError>;
|
||||
fn to_tombstone(&self) -> Result<Tombstone, LemmyError>;
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
pub(crate) trait FromApub {
|
||||
type ApubType;
|
||||
/// Converts an object from ActivityPub type to Lemmy internal type.
|
||||
///
|
||||
/// * `apub` The object to read from
|
||||
/// * `context` LemmyContext which holds DB pool, HTTP client etc
|
||||
/// * `expected_domain` Domain where the object was received from
|
||||
async fn from_apub(
|
||||
apub: &Self::ApubType,
|
||||
context: &LemmyContext,
|
||||
expected_domain: Url,
|
||||
request_counter: &mut i32,
|
||||
) -> Result<Self, LemmyError>
|
||||
where
|
||||
Self: Sized;
|
||||
}
|
||||
|
||||
#[async_trait::async_trait(?Send)]
|
||||
pub(in crate::objects) trait FromApubToForm<ApubType> {
|
||||
async fn from_apub(
|
||||
apub: &ApubType,
|
||||
context: &LemmyContext,
|
||||
expected_domain: Url,
|
||||
request_counter: &mut i32,
|
||||
) -> Result<Self, LemmyError>
|
||||
where
|
||||
Self: Sized;
|
||||
}
|
||||
|
||||
/// Updated is actually the deletion time
|
||||
fn create_tombstone<T>(
|
||||
deleted: bool,
|
||||
object_id: Url,
|
||||
updated: Option<NaiveDateTime>,
|
||||
former_type: T,
|
||||
) -> Result<Tombstone, LemmyError>
|
||||
where
|
||||
T: ToString,
|
||||
{
|
||||
if deleted {
|
||||
if let Some(updated) = updated {
|
||||
let mut tombstone = Tombstone::new();
|
||||
tombstone.set_id(object_id);
|
||||
tombstone.set_former_type(former_type.to_string());
|
||||
tombstone.set_deleted(convert_datetime(updated));
|
||||
Ok(tombstone)
|
||||
} else {
|
||||
Err(anyhow!("Cant convert to tombstone because updated time was None.").into())
|
||||
}
|
||||
} else {
|
||||
Err(anyhow!("Cant convert object to tombstone if it wasnt deleted").into())
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::objects) fn check_object_domain<T, Kind>(
|
||||
apub: &T,
|
||||
expected_domain: Url,
|
||||
) -> Result<DbUrl, LemmyError>
|
||||
where
|
||||
T: Base + AsBase<Kind>,
|
||||
{
|
||||
let domain = expected_domain.domain().context(location_info!())?;
|
||||
let object_id = apub.id(domain)?.context(location_info!())?;
|
||||
check_is_apub_id_valid(object_id)?;
|
||||
Ok(object_id.to_owned().into())
|
||||
}
|
||||
|
||||
pub(in crate::objects) fn set_content_and_source<T, Kind1, Kind2>(
|
||||
object: &mut T,
|
||||
markdown_text: &str,
|
||||
) -> Result<(), LemmyError>
|
||||
where
|
||||
T: ApObjectExt<Kind1> + ObjectExt<Kind2> + AsBase<Kind2>,
|
||||
{
|
||||
let mut source = Object::<()>::new_none_type();
|
||||
source
|
||||
.set_content(markdown_text)
|
||||
.set_media_type(mime_markdown()?);
|
||||
object.set_source(source.into_any_base()?);
|
||||
|
||||
object.set_content(markdown_to_html(markdown_text));
|
||||
object.set_media_type(mime_html()?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(in crate::objects) fn get_source_markdown_value<T, Kind1, Kind2>(
|
||||
object: &T,
|
||||
) -> Result<Option<String>, LemmyError>
|
||||
where
|
||||
T: ApObjectExt<Kind1> + ObjectExt<Kind2> + AsBase<Kind2>,
|
||||
{
|
||||
let content = object
|
||||
.content()
|
||||
.map(|s| s.as_single_xsd_string())
|
||||
.flatten()
|
||||
.map(|s| s.to_string());
|
||||
if content.is_some() {
|
||||
let source = object.source().context(location_info!())?;
|
||||
let source = Object::<()>::from_any_base(source.to_owned())?.context(location_info!())?;
|
||||
check_is_markdown(source.media_type())?;
|
||||
let source_content = source
|
||||
.content()
|
||||
.map(|s| s.as_single_xsd_string())
|
||||
.flatten()
|
||||
.context(location_info!())?
|
||||
.to_string();
|
||||
return Ok(Some(source_content));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn mime_markdown() -> Result<Mime, FromStrError> {
|
||||
"text/markdown".parse()
|
||||
}
|
||||
|
||||
fn mime_html() -> Result<Mime, FromStrError> {
|
||||
"text/html".parse()
|
||||
}
|
||||
|
||||
pub(in crate::objects) fn check_is_markdown(mime: Option<&Mime>) -> Result<(), LemmyError> {
|
||||
let mime = mime.context(location_info!())?;
|
||||
if !mime.eq(&mime_markdown()?) {
|
||||
Err(LemmyError::from(anyhow!(
|
||||
"Lemmy only supports markdown content"
|
||||
)))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts an ActivityPub object (eg `Note`) to a database object (eg `Comment`). If an object
|
||||
/// with the same ActivityPub ID already exists in the database, it is returned directly. Otherwise
|
||||
/// the apub object is parsed, inserted and returned.
|
||||
pub(in crate::objects) async fn get_object_from_apub<From, Kind, To, ToForm, IdType>(
|
||||
from: &From,
|
||||
context: &LemmyContext,
|
||||
expected_domain: Url,
|
||||
request_counter: &mut i32,
|
||||
) -> Result<To, LemmyError>
|
||||
where
|
||||
From: BaseExt<Kind>,
|
||||
To: ApubObject<ToForm> + Crud<ToForm, IdType> + Send + 'static,
|
||||
ToForm: FromApubToForm<From> + Send + 'static,
|
||||
{
|
||||
let object_id = from.id_unchecked().context(location_info!())?.to_owned();
|
||||
let domain = object_id.domain().context(location_info!())?;
|
||||
|
||||
// if its a local object, return it directly from the database
|
||||
if Settings::get().hostname() == domain {
|
||||
let object = blocking(context.pool(), move |conn| {
|
||||
To::read_from_apub_id(conn, &object_id.into())
|
||||
})
|
||||
.await??;
|
||||
Ok(object)
|
||||
}
|
||||
// otherwise parse and insert, assuring that it comes from the right domain
|
||||
else {
|
||||
let to_form = ToForm::from_apub(&from, context, expected_domain, request_counter).await?;
|
||||
|
||||
let to = blocking(context.pool(), move |conn| To::upsert(conn, &to_form)).await??;
|
||||
Ok(to)
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::objects) async fn check_object_for_community_or_site_ban<T, Kind>(
|
||||
object: &T,
|
||||
community_id: CommunityId,
|
||||
context: &LemmyContext,
|
||||
request_counter: &mut i32,
|
||||
) -> Result<(), LemmyError>
|
||||
where
|
||||
T: ObjectExt<Kind>,
|
||||
{
|
||||
let person_id = object
|
||||
.attributed_to()
|
||||
.context(location_info!())?
|
||||
.as_single_xsd_any_uri()
|
||||
.context(location_info!())?;
|
||||
let person = get_or_fetch_and_upsert_person(person_id, context, request_counter).await?;
|
||||
check_community_or_site_ban(&person, community_id, context.pool()).await
|
||||
}
|
||||
|
||||
pub(in crate::objects) async fn get_to_community<T, Kind>(
|
||||
object: &T,
|
||||
context: &LemmyContext,
|
||||
request_counter: &mut i32,
|
||||
) -> Result<Community, LemmyError>
|
||||
where
|
||||
T: ObjectExt<Kind>,
|
||||
{
|
||||
let community_ids = object
|
||||
.to()
|
||||
.context(location_info!())?
|
||||
.as_many()
|
||||
.context(location_info!())?
|
||||
.iter()
|
||||
.map(|a| a.as_xsd_any_uri().context(location_info!()))
|
||||
.collect::<Result<Vec<&Url>, anyhow::Error>>()?;
|
||||
for cid in community_ids {
|
||||
let community = get_or_fetch_and_upsert_community(&cid, context, request_counter).await;
|
||||
if community.is_ok() {
|
||||
return community;
|
||||
}
|
||||
}
|
||||
Err(NotFound.into())
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue