mirror of
https://github.com/NodeBB/NodeBB.git
synced 2025-12-16 13:30:23 +01:00
Compare commits
814 Commits
custom-use
...
v4.0.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86f624f817 | ||
|
|
34c4cefe0c | ||
|
|
aa060d31d1 | ||
|
|
ad6d03266b | ||
|
|
4ae8614573 | ||
|
|
6b4b4b1a00 | ||
|
|
6752a54116 | ||
|
|
71ce308936 | ||
|
|
d0ac5ff99f | ||
|
|
b04a11055f | ||
|
|
047d5992dc | ||
|
|
1fe8ac191d | ||
|
|
3c55d1024d | ||
|
|
1a0a2cd17b | ||
|
|
d7ae6a5b9b | ||
|
|
d6bab25317 | ||
|
|
028b6d74b2 | ||
|
|
471fbd3ab4 | ||
|
|
42edde63ca | ||
|
|
1fbf911489 | ||
|
|
d4cf5e7ee0 | ||
|
|
ad565a98ea | ||
|
|
cf6c4c52b8 | ||
|
|
c2600b124f | ||
|
|
5109b53921 | ||
|
|
40fe4d88f9 | ||
|
|
fc4f6a4c56 | ||
|
|
176f4d0d09 | ||
|
|
d42d3b1c39 | ||
|
|
97edcf6a48 | ||
|
|
a80e314169 | ||
|
|
6010b72d71 | ||
|
|
652d6c6e2b | ||
|
|
045e16d3ce | ||
|
|
909437c306 | ||
|
|
6ed32f6781 | ||
|
|
0f63f67b77 | ||
|
|
9357e71898 | ||
|
|
b63440cb14 | ||
|
|
16c8a6523a | ||
|
|
ba2c3fcef4 | ||
|
|
f3eb6d500e | ||
|
|
e3bfcb0230 | ||
|
|
daa665ebc0 | ||
|
|
76551c7123 | ||
|
|
df6062d8be | ||
|
|
e6d8e05932 | ||
|
|
22952c5407 | ||
|
|
e287956323 | ||
|
|
c3e14912c9 | ||
|
|
25bf97ae28 | ||
|
|
3474712228 | ||
|
|
f481cde1a8 | ||
|
|
95e6d2b43c | ||
|
|
53aee40149 | ||
|
|
fc81d6e035 | ||
|
|
bd11d86b17 | ||
|
|
cbe0a0ad5c | ||
|
|
d33fb92d9d | ||
|
|
9353638668 | ||
|
|
709a02d97a | ||
|
|
ff08fbb73f | ||
|
|
9bd6896d13 | ||
|
|
e015339ccf | ||
|
|
2ced350250 | ||
|
|
1f896fb6ba | ||
|
|
9a54f6bc7d | ||
|
|
3e494dc757 | ||
|
|
8971bba53d | ||
|
|
399d41030f | ||
|
|
7bf349b62b | ||
|
|
83993cad17 | ||
|
|
fc5a829c0c | ||
|
|
b9f37c5c32 | ||
|
|
be393d571c | ||
|
|
39f92eac16 | ||
|
|
a884681abf | ||
|
|
5c9ac5d330 | ||
|
|
f9937a84c4 | ||
|
|
2aa0483718 | ||
|
|
60408f2307 | ||
|
|
bc00835c26 | ||
|
|
609035b587 | ||
|
|
ff0c289e1d | ||
|
|
da25fd21ea | ||
|
|
26765fe9aa | ||
|
|
2a8f7ccd1c | ||
|
|
cfbaf145f4 | ||
|
|
f6f01ba345 | ||
|
|
c01f154937 | ||
|
|
7bdf376971 | ||
|
|
530241e0f2 | ||
|
|
9b8c834ef0 | ||
|
|
2aeabce566 | ||
|
|
9219199591 | ||
|
|
31c7226e34 | ||
|
|
3c0d7616db | ||
|
|
ad05f06b00 | ||
|
|
a81ef60930 | ||
|
|
fdc0d670d7 | ||
|
|
00a266790d | ||
|
|
f605e188db | ||
|
|
485b775837 | ||
|
|
5e343a83f8 | ||
|
|
bacbfba9bd | ||
|
|
3c9337b730 | ||
|
|
e84cfd8c07 | ||
|
|
b0eec67d78 | ||
|
|
af6ae6ee8a | ||
|
|
26a7c51a9a | ||
|
|
352857cfb6 | ||
|
|
8e1fccf014 | ||
|
|
78a6c60cf5 | ||
|
|
f629b20f2d | ||
|
|
3a05171f3c | ||
|
|
4949d6c4e5 | ||
|
|
8ecbb5cc5c | ||
|
|
46341673b3 | ||
|
|
0d549f1d80 | ||
|
|
d92efb8a0c | ||
|
|
f662a60667 | ||
|
|
26946c7fe6 | ||
|
|
2cbd63dc31 | ||
|
|
a748068c05 | ||
|
|
eab231ee9f | ||
|
|
cb5e5b4bdb | ||
|
|
600b1a8622 | ||
|
|
7e23e192d8 | ||
|
|
644bcec562 | ||
|
|
447b99423f | ||
|
|
20aee8e9ae | ||
|
|
cc0bbcb7bf | ||
|
|
a2fb939b5c | ||
|
|
e75ec39b70 | ||
|
|
615aaa01d6 | ||
|
|
19908038c5 | ||
|
|
d6f44d8ec3 | ||
|
|
6b33faac54 | ||
|
|
4dbb73a433 | ||
|
|
fb1a9178cc | ||
|
|
fe70a2b8b5 | ||
|
|
ef97a784cf | ||
|
|
09d8fbf978 | ||
|
|
b66d998d37 | ||
|
|
a371c9fceb | ||
|
|
89dd2fb04a | ||
|
|
16fe85e2e4 | ||
|
|
e84c797c7c | ||
|
|
789520e445 | ||
|
|
7a5588da7b | ||
|
|
1be3f49e75 | ||
|
|
16f8f536da | ||
|
|
0573e8f3de | ||
|
|
f35a6c631e | ||
|
|
b691d2a9b2 | ||
|
|
c8b39007de | ||
|
|
d9ac7f4995 | ||
|
|
8003946feb | ||
|
|
7d679db28e | ||
|
|
7fada44bf2 | ||
|
|
4b86b37889 | ||
|
|
a3c6c872b9 | ||
|
|
daed74134a | ||
|
|
59818031bc | ||
|
|
5a2fa52ccc | ||
|
|
b3b34ebad6 | ||
|
|
aae0b5b09f | ||
|
|
529cd3712b | ||
|
|
4eb998d59d | ||
|
|
e6e6c2a28a | ||
|
|
6e2178b0dc | ||
|
|
d0a1ebcff7 | ||
|
|
4b8a9e58ae | ||
|
|
a458bdd638 | ||
|
|
b540ecd313 | ||
|
|
c021e7e80f | ||
|
|
05b7828e33 | ||
|
|
18f9baffbc | ||
|
|
232fcc005f | ||
|
|
bb6f978508 | ||
|
|
6042f4a7ed | ||
|
|
ad22b54e5e | ||
|
|
820d576481 | ||
|
|
ddda8feb21 | ||
|
|
d550dc2728 | ||
|
|
c231ab78b2 | ||
|
|
c276aa1a99 | ||
|
|
15797f6aa8 | ||
|
|
4030c09e27 | ||
|
|
6569066ef8 | ||
|
|
c8bc6e551b | ||
|
|
931a0f0a60 | ||
|
|
6069bee019 | ||
|
|
89d2363d5e | ||
|
|
be95b5b122 | ||
|
|
f5b856f4fd | ||
|
|
3f1b04ebe8 | ||
|
|
e9a0d49593 | ||
|
|
f6d4d5630e | ||
|
|
691f691749 | ||
|
|
827a91ea6b | ||
|
|
632ba51c7f | ||
|
|
18e583184f | ||
|
|
6227ab900e | ||
|
|
937caecdc4 | ||
|
|
1219d7d3b8 | ||
|
|
839928b390 | ||
|
|
c95075e1ca | ||
|
|
291bf7db41 | ||
|
|
f76a586328 | ||
|
|
b6ffc47c5d | ||
|
|
d9f1fa3ad3 | ||
|
|
3567f55a5d | ||
|
|
101062cb92 | ||
|
|
acd1630b9f | ||
|
|
badb7e2c05 | ||
|
|
578a53dd8e | ||
|
|
919b037a8f | ||
|
|
68c916503a | ||
|
|
3eca978509 | ||
|
|
a67e1b7caf | ||
|
|
b2d03da96d | ||
|
|
f29214e007 | ||
|
|
cdc6f9f6a3 | ||
|
|
2b6e31a635 | ||
|
|
f075a8c148 | ||
|
|
194a9fca44 | ||
|
|
04b4c16e27 | ||
|
|
42f514b793 | ||
|
|
bb0360bf01 | ||
|
|
34dcb44992 | ||
|
|
7313d5c614 | ||
|
|
2c87c6e82f | ||
|
|
552b07b258 | ||
|
|
634e9b03f1 | ||
|
|
89465ec604 | ||
|
|
bad094e888 | ||
|
|
a8d41978e5 | ||
|
|
eb6fe3397e | ||
|
|
00c70ce7b0 | ||
|
|
ae3fa85f40 | ||
|
|
6272d059e7 | ||
|
|
57913be591 | ||
|
|
bcfac00bec | ||
|
|
dac2cd2549 | ||
|
|
4f2ccdaa9f | ||
|
|
3488a4bfe1 | ||
|
|
70625133ae | ||
|
|
0d645c994f | ||
|
|
58668b46d3 | ||
|
|
dbd901d318 | ||
|
|
ded05b83fc | ||
|
|
1ac793ec2b | ||
|
|
869df3c2bc | ||
|
|
6f831d1ce2 | ||
|
|
b1f9ad5534 | ||
|
|
6ecc791db9 | ||
|
|
4bb2c1a85e | ||
|
|
8d790964be | ||
|
|
d442251f22 | ||
|
|
f83a3672ca | ||
|
|
539300ffec | ||
|
|
3dca79dd3b | ||
|
|
c6f2155f6a | ||
|
|
d1625d5fd2 | ||
|
|
29bf99ac85 | ||
|
|
2ae5857005 | ||
|
|
ba2d18418a | ||
|
|
f8d9f644e6 | ||
|
|
eaf435413e | ||
|
|
35eb2d0d46 | ||
|
|
119230d7ec | ||
|
|
c680fa67d9 | ||
|
|
c4c3c20789 | ||
|
|
9868a728bf | ||
|
|
709c91329a | ||
|
|
300cf79c95 | ||
|
|
6f37825b1a | ||
|
|
76290e3789 | ||
|
|
4cec67832c | ||
|
|
47406d1309 | ||
|
|
547fc17dcf | ||
|
|
f2e43e894b | ||
|
|
5ec24977db | ||
|
|
ff53f3c02c | ||
|
|
7c79e2310a | ||
|
|
09e71e94a2 | ||
|
|
b6653d153a | ||
|
|
e536223ed0 | ||
|
|
4fd0d27e21 | ||
|
|
8cf9617630 | ||
|
|
78e11d6eba | ||
|
|
f93b237ef1 | ||
|
|
a005440088 | ||
|
|
228c9173f3 | ||
|
|
4e9cd8efc0 | ||
|
|
b106a6a018 | ||
|
|
70d2a6249d | ||
|
|
129017f735 | ||
|
|
1fa9346563 | ||
|
|
b5b89038ac | ||
|
|
2d7ca48d42 | ||
|
|
d40d0b06ac | ||
|
|
5bfde4c4a5 | ||
|
|
0a1ad87d64 | ||
|
|
b67a7c3c15 | ||
|
|
9fbd48b152 | ||
|
|
9f44e99fae | ||
|
|
eda97da14a | ||
|
|
336ea1eeb6 | ||
|
|
baada2fca7 | ||
|
|
ccd187e000 | ||
|
|
fdba684049 | ||
|
|
c973a9caba | ||
|
|
43dfb60f91 | ||
|
|
f404395962 | ||
|
|
6f9fa66006 | ||
|
|
96db519b79 | ||
|
|
51089a347a | ||
|
|
5e9d47a1d8 | ||
|
|
0c0f01b560 | ||
|
|
2cd9088c8d | ||
|
|
ac74d9c55e | ||
|
|
9761526710 | ||
|
|
a7aeabc80d | ||
|
|
af98675808 | ||
|
|
22b42f11dd | ||
|
|
4cbb1f2a42 | ||
|
|
729f0fcac1 | ||
|
|
ea6e15bf08 | ||
|
|
50bc9a37c5 | ||
|
|
4e7b12b925 | ||
|
|
807c3eac12 | ||
|
|
e341a5d868 | ||
|
|
4e006adb58 | ||
|
|
738d47c7db | ||
|
|
8de2352e64 | ||
|
|
05cdb7d920 | ||
|
|
109f423211 | ||
|
|
1a21c8add4 | ||
|
|
4b29a1b91d | ||
|
|
32d1adf67d | ||
|
|
ae589ad71c | ||
|
|
f24a1a34ce | ||
|
|
0336e8f0ad | ||
|
|
3636dec76b | ||
|
|
119800d936 | ||
|
|
5e2031977a | ||
|
|
6ac5a77976 | ||
|
|
fc3f3a7831 | ||
|
|
449ffd66c3 | ||
|
|
61a595def2 | ||
|
|
53b352d0d8 | ||
|
|
b9d6aa4639 | ||
|
|
cf291a518d | ||
|
|
b7ad4317cc | ||
|
|
596a5e4ba2 | ||
|
|
4d777552d8 | ||
|
|
7645365007 | ||
|
|
40b9dabda3 | ||
|
|
1ce986f7b5 | ||
|
|
c041108b47 | ||
|
|
fd35dd17b1 | ||
|
|
94eafe1df3 | ||
|
|
5e776088c9 | ||
|
|
e9df15bbb8 | ||
|
|
af324dae55 | ||
|
|
7180819c21 | ||
|
|
ed84eed8a9 | ||
|
|
dd71340af9 | ||
|
|
ee6779445b | ||
|
|
a853dabdc4 | ||
|
|
6964b1fd56 | ||
|
|
9829a5300e | ||
|
|
91c3434fda | ||
|
|
1ecbf02a58 | ||
|
|
844d1402ba | ||
|
|
700016649d | ||
|
|
8fd4ae2d8f | ||
|
|
3d1e2cde6a | ||
|
|
ab055a77a2 | ||
|
|
2ce3a11645 | ||
|
|
59021d8a9f | ||
|
|
3721f8196d | ||
|
|
f802564e64 | ||
|
|
a9a7fd1ecd | ||
|
|
dce334461a | ||
|
|
49a64dc7a7 | ||
|
|
452cb0a9aa | ||
|
|
1253ded7d5 | ||
|
|
407dda78e1 | ||
|
|
59a9dd8436 | ||
|
|
b7ff7be28f | ||
|
|
b7629c6ef7 | ||
|
|
026449dc4a | ||
|
|
a1a7fb77da | ||
|
|
c1dfa8d2fe | ||
|
|
2a2b855fe2 | ||
|
|
7bacbf76f0 | ||
|
|
9ab1a2d129 | ||
|
|
7c4ae8eb82 | ||
|
|
6befff5e11 | ||
|
|
64258dac19 | ||
|
|
50f31b236a | ||
|
|
fdbd30dc23 | ||
|
|
bb4e418dde | ||
|
|
c3365908b8 | ||
|
|
4d07ca4c6a | ||
|
|
18ba2e38ec | ||
|
|
52271caec2 | ||
|
|
4d23a837fa | ||
|
|
f783338621 | ||
|
|
525b7c2cee | ||
|
|
f5ea470df4 | ||
|
|
8d0876b8d7 | ||
|
|
6fc6cc33cd | ||
|
|
b8daa02fa8 | ||
|
|
959e1820cd | ||
|
|
e0f542c912 | ||
|
|
465a46f2e6 | ||
|
|
c92b5e07a7 | ||
|
|
3cc09d204f | ||
|
|
66b0d81caf | ||
|
|
e0166cccb2 | ||
|
|
962c63511c | ||
|
|
b6168202ae | ||
|
|
41f77f8f1b | ||
|
|
72dbcfe3d9 | ||
|
|
b921f8d167 | ||
|
|
102c174e03 | ||
|
|
d437d969cc | ||
|
|
fcd5447cd4 | ||
|
|
464dd8067d | ||
|
|
6b169e048e | ||
|
|
86107535db | ||
|
|
de83d6b01c | ||
|
|
4e0d7dd364 | ||
|
|
c30c12881c | ||
|
|
2d1524eeff | ||
|
|
e25f06212f | ||
|
|
d9eba6d297 | ||
|
|
cd8a91fe89 | ||
|
|
5c1c1d2182 | ||
|
|
9fc194e3a8 | ||
|
|
8003b00acd | ||
|
|
cbfc8d252d | ||
|
|
86caf237bf | ||
|
|
719bb0a0ec | ||
|
|
f40df38786 | ||
|
|
983153fbba | ||
|
|
42bdffdc94 | ||
|
|
ae146024af | ||
|
|
166e3e2f95 | ||
|
|
442e98e4da | ||
|
|
943cef6da2 | ||
|
|
0b2faf21f9 | ||
|
|
59709a3cb2 | ||
|
|
1084f21d47 | ||
|
|
e828e87ec7 | ||
|
|
893788a2fc | ||
|
|
2cbc391382 | ||
|
|
d1fa6a596b | ||
|
|
a3dce46371 | ||
|
|
e266b44745 | ||
|
|
3c6966fb0f | ||
|
|
495d5435f5 | ||
|
|
e0138cbede | ||
|
|
a38d0c14ab | ||
|
|
f078e1e267 | ||
|
|
4fcd2bb2d9 | ||
|
|
6c1a0fb1dc | ||
|
|
5efe8abd15 | ||
|
|
dff5d1c6cd | ||
|
|
0e2daa58aa | ||
|
|
8dcdf8ef3b | ||
|
|
c2890a3e74 | ||
|
|
550e522fe9 | ||
|
|
017a57e645 | ||
|
|
55e947a01d | ||
|
|
2688b6bbdc | ||
|
|
ac765f1e01 | ||
|
|
2ffdbebd21 | ||
|
|
1fb3c4fda6 | ||
|
|
bab41d129a | ||
|
|
04c743eb4a | ||
|
|
4ee8519d0c | ||
|
|
e0f6b7074b | ||
|
|
7df5cabb76 | ||
|
|
803975fd97 | ||
|
|
9dc20d0c54 | ||
|
|
3cc99a178e | ||
|
|
aafdefa7d6 | ||
|
|
65bb866654 | ||
|
|
9c03e6e93c | ||
|
|
003c64690f | ||
|
|
da2a1e207a | ||
|
|
9a5d4ffb2d | ||
|
|
8f131b71ee | ||
|
|
17f62f249d | ||
|
|
2756aae7be | ||
|
|
2408ecd8dc | ||
|
|
6fa43b9ef0 | ||
|
|
c9985c5cd6 | ||
|
|
5a2d17dfd1 | ||
|
|
d75894eb9b | ||
|
|
1027c6e6dd | ||
|
|
953231fc90 | ||
|
|
5f60f75f7d | ||
|
|
c76d4018f2 | ||
|
|
7af0715146 | ||
|
|
a84dba271c | ||
|
|
ccd9c7ed85 | ||
|
|
ff6cc90ca5 | ||
|
|
1a92239c15 | ||
|
|
44a183dd84 | ||
|
|
0a2482cf51 | ||
|
|
7aa56c727b | ||
|
|
e389aec288 | ||
|
|
ebd750b4c7 | ||
|
|
c346177bb9 | ||
|
|
83392f3ca2 | ||
|
|
0bfdbb6a5c | ||
|
|
c5c0c47393 | ||
|
|
81b319f494 | ||
|
|
c6624b6341 | ||
|
|
519e025e27 | ||
|
|
338c568c99 | ||
|
|
f7c8967ee2 | ||
|
|
52c143aa03 | ||
|
|
4b2495b5aa | ||
|
|
01f7af9291 | ||
|
|
eb8ff75b63 | ||
|
|
b1491ca3cd | ||
|
|
9cd6b496ba | ||
|
|
ba3eae7a64 | ||
|
|
5768a4377b | ||
|
|
8bf6130d17 | ||
|
|
ceab77e43b | ||
|
|
17c55922d4 | ||
|
|
90e7d3a1c6 | ||
|
|
1e6632392b | ||
|
|
66b4dc2c96 | ||
|
|
97e5b0fdad | ||
|
|
08184f29c1 | ||
|
|
00101d9e29 | ||
|
|
6423d514ff | ||
|
|
f31d8c2e9b | ||
|
|
6aece6893b | ||
|
|
d31978b3a8 | ||
|
|
c7e2b0a2b0 | ||
|
|
39da3ab668 | ||
|
|
4a8b06e815 | ||
|
|
6999bd68e2 | ||
|
|
28f38b2687 | ||
|
|
81b81e540a | ||
|
|
beaedc86aa | ||
|
|
9f8c706f23 | ||
|
|
95427c4af7 | ||
|
|
0c2cfbe7a0 | ||
|
|
4c2cbb5b12 | ||
|
|
5b74f6dac5 | ||
|
|
f368a1a87f | ||
|
|
cda25bb646 | ||
|
|
385b8c8a52 | ||
|
|
706fa17429 | ||
|
|
6671b51169 | ||
|
|
aef1215fd6 | ||
|
|
ae09aa2086 | ||
|
|
e4c1ca1ede | ||
|
|
1b64fdb5b3 | ||
|
|
07c1ea2876 | ||
|
|
63e2dc9ae4 | ||
|
|
038f726b23 | ||
|
|
c707a32aa9 | ||
|
|
0f99ae1f45 | ||
|
|
cb8d38ee2f | ||
|
|
e1c4311299 | ||
|
|
f5a610797c | ||
|
|
1ca9994f43 | ||
|
|
b529610bba | ||
|
|
0cf6af9b6c | ||
|
|
8cb984d44c | ||
|
|
44452a3b6e | ||
|
|
a08b2efb5e | ||
|
|
bdcd862c1b | ||
|
|
96a3a7465f | ||
|
|
d51f5e5922 | ||
|
|
0b6b86d132 | ||
|
|
a3368a6904 | ||
|
|
b189427621 | ||
|
|
281e9d6e8c | ||
|
|
10abb82e8a | ||
|
|
3d83a1752e | ||
|
|
77a3efb43c | ||
|
|
5f85e70006 | ||
|
|
aadac7053a | ||
|
|
563db78f87 | ||
|
|
1bd8f9a1fa | ||
|
|
28370b1043 | ||
|
|
b011595198 | ||
|
|
3a870360c4 | ||
|
|
eaba2aa37f | ||
|
|
49c69a0f6a | ||
|
|
175521ba67 | ||
|
|
92a8951bca | ||
|
|
a94341f489 | ||
|
|
ed4ccbfccc | ||
|
|
42a0924137 | ||
|
|
e138b915b9 | ||
|
|
3e02efcdb9 | ||
|
|
893dd523ca | ||
|
|
53d1c10ec4 | ||
|
|
31fd135f6e | ||
|
|
cfbbf4ca86 | ||
|
|
d77de088e6 | ||
|
|
6a7f8f146f | ||
|
|
0edaf17c6a | ||
|
|
e5b6ee97bd | ||
|
|
8b0ccc8090 | ||
|
|
068c4fa90e | ||
|
|
56cfae0766 | ||
|
|
5c04e8051c | ||
|
|
19d017b942 | ||
|
|
6a11c89b90 | ||
|
|
76d5feb7d6 | ||
|
|
814c479405 | ||
|
|
9439987eda | ||
|
|
672a907d54 | ||
|
|
609a9a37e5 | ||
|
|
22da3a6a80 | ||
|
|
69f1ace3e5 | ||
|
|
d13980c8fb | ||
|
|
de71b733f8 | ||
|
|
8912863423 | ||
|
|
da085b0ece | ||
|
|
401b4c5fa6 | ||
|
|
12968b6291 | ||
|
|
ce4b5679a2 | ||
|
|
5fee4e3306 | ||
|
|
11dba85d0a | ||
|
|
ac56289fa2 | ||
|
|
874b24cbcf | ||
|
|
2b1a347792 | ||
|
|
f1b8f3fe4f | ||
|
|
944de4ea26 | ||
|
|
4fb6574cf7 | ||
|
|
a61e7fe79b | ||
|
|
08f8babd9f | ||
|
|
aafdebd211 | ||
|
|
80377599d1 | ||
|
|
6508287db5 | ||
|
|
ec2b375ae9 | ||
|
|
94dcd29e63 | ||
|
|
9795abbf58 | ||
|
|
00efbd6dd9 | ||
|
|
415b4fe11a | ||
|
|
21a2876e9c | ||
|
|
b9821c5206 | ||
|
|
f7259ccb8b | ||
|
|
a461e5dd41 | ||
|
|
25f0d48432 | ||
|
|
35819cc953 | ||
|
|
42bb3c3399 | ||
|
|
d2f14c363d | ||
|
|
d18e65c473 | ||
|
|
d213ce790c | ||
|
|
88733a5160 | ||
|
|
ae042ce39e | ||
|
|
607c4623c7 | ||
|
|
94361721b1 | ||
|
|
6930973d7a | ||
|
|
0e59f3124e | ||
|
|
86a607ce8c | ||
|
|
457bfe1685 | ||
|
|
fef1882473 | ||
|
|
a3a0edb70b | ||
|
|
6e87cf57a2 | ||
|
|
fa1c549002 | ||
|
|
ef8cd34ba1 | ||
|
|
2c8342632f | ||
|
|
a355c05d83 | ||
|
|
25434d2a6e | ||
|
|
ddf17ee5fb | ||
|
|
6b517252b9 | ||
|
|
0b3ca8e366 | ||
|
|
5de4f08412 | ||
|
|
e07c31316e | ||
|
|
3c24cfb4a9 | ||
|
|
0af97dd444 | ||
|
|
8453b83e9e | ||
|
|
f51bbe91e7 | ||
|
|
1ee03f2b83 | ||
|
|
e00a03bb9c | ||
|
|
911177ceda | ||
|
|
0e016c6ecd | ||
|
|
c9feb92539 | ||
|
|
3bdaa8a836 | ||
|
|
da0211b1a0 | ||
|
|
f8cfe64c7e | ||
|
|
4cc7ee6501 | ||
|
|
92c990f2f7 | ||
|
|
2ce14f5019 | ||
|
|
2cb370882b | ||
|
|
9885f94a2b | ||
|
|
403bf3e1a8 | ||
|
|
9e58b04c4e | ||
|
|
47b3ffb9b1 | ||
|
|
76c6e30282 | ||
|
|
a3e1a666b8 | ||
|
|
2ff70fdde2 | ||
|
|
981b4f146d | ||
|
|
33f3da8a64 | ||
|
|
518169fe65 | ||
|
|
538776f9c7 | ||
|
|
04423232c6 | ||
|
|
970a5a6e5f | ||
|
|
60bc27ec69 | ||
|
|
e038eb0509 | ||
|
|
9c15b02aa6 | ||
|
|
ac672f08a7 | ||
|
|
641a94d64a | ||
|
|
f74775365a | ||
|
|
7565485204 | ||
|
|
8cd3ff1ab4 | ||
|
|
5ac0276530 | ||
|
|
28c3dcb626 | ||
|
|
2981f663ce | ||
|
|
7e89eadb36 | ||
|
|
81d810983a | ||
|
|
4bd7a574c3 | ||
|
|
273188632d | ||
|
|
485cf20006 | ||
|
|
d992239d7b | ||
|
|
326bb995b1 | ||
|
|
2b3b6e56af | ||
|
|
ab40ba6167 | ||
|
|
3ce84b39bc | ||
|
|
2bd9e9847d | ||
|
|
0d478b2c76 | ||
|
|
06e1583461 | ||
|
|
f1e5e5a0ad | ||
|
|
91a509c4eb | ||
|
|
d1cdb8f858 | ||
|
|
5e1e809962 | ||
|
|
9221506b0f | ||
|
|
ec3c6b67cd | ||
|
|
7ec75f5830 | ||
|
|
a1c8c3a043 | ||
|
|
942a9b7e5c | ||
|
|
2a77ae550b | ||
|
|
c66b18670d | ||
|
|
7fd15d6668 | ||
|
|
27e256d26e | ||
|
|
fcca9259b4 | ||
|
|
283e460a57 | ||
|
|
e4eeb069c2 | ||
|
|
739b05481c | ||
|
|
672c70146a | ||
|
|
2e330d8b3a | ||
|
|
8d4fd9c0f8 | ||
|
|
759d69e06c | ||
|
|
ccbf32bcc5 | ||
|
|
3339baac18 | ||
|
|
b93a9eec7e | ||
|
|
6a54e0090b | ||
|
|
ac3440114a | ||
|
|
543daee866 | ||
|
|
f3b0794d17 | ||
|
|
0eadad84cd | ||
|
|
71e3d26bb4 | ||
|
|
dd480aa4f6 | ||
|
|
885278842b | ||
|
|
8d79617044 | ||
|
|
00bda0688d | ||
|
|
5c881d3f51 | ||
|
|
510763af30 | ||
|
|
0d6f0530f5 | ||
|
|
a21110fd88 | ||
|
|
24c1dfac8c | ||
|
|
48fd2e6441 | ||
|
|
7f46f07cb9 | ||
|
|
4c1b2b3fe6 | ||
|
|
68d5e4a8ab | ||
|
|
1f79f54241 | ||
|
|
c803b2124c | ||
|
|
8a5fb86ddf | ||
|
|
e794f1d2ce | ||
|
|
4324f09c85 | ||
|
|
5e693702a4 | ||
|
|
99cc60c8d5 | ||
|
|
cc0d18869a | ||
|
|
bcee1c8dc8 | ||
|
|
4218ecc4a0 | ||
|
|
c02271c7af | ||
|
|
e6753ce5db | ||
|
|
9dfa1b7209 | ||
|
|
5d95765ee7 | ||
|
|
9f94653b3f | ||
|
|
cdc4275fec | ||
|
|
2e89900886 | ||
|
|
a10df9873b | ||
|
|
e7184eb8cc | ||
|
|
4f5f025d57 | ||
|
|
57895b7246 | ||
|
|
ab3ff320b5 | ||
|
|
0cbbce8c16 | ||
|
|
a05b674e27 | ||
|
|
7e1dac39ea | ||
|
|
81b6260f2e | ||
|
|
099124c49e | ||
|
|
1c8e13bb12 | ||
|
|
4bd8d28a8b | ||
|
|
2dec357aee | ||
|
|
51d8f3b195 |
@@ -190,5 +190,10 @@
|
||||
"composer:allowPluginHelp": 1,
|
||||
"maxReconnectionAttempts": 5,
|
||||
"reconnectionDelay": 1500,
|
||||
"disableCustomUserSkins": 0
|
||||
"disableCustomUserSkins": 0,
|
||||
"activitypubEnabled": 1,
|
||||
"activitypubAllowLoopback": 0,
|
||||
"activitypubContentPruneDays": 30,
|
||||
"activitypubUserPruneDays": 7,
|
||||
"activitypubFilter": 0
|
||||
}
|
||||
|
||||
@@ -41,6 +41,14 @@
|
||||
"textClass": "d-lg-none",
|
||||
"text": "[[global:header.popular]]"
|
||||
},
|
||||
{
|
||||
"route": "/world",
|
||||
"title": "[[global:header.world]]",
|
||||
"enabled": true,
|
||||
"iconClass": "fa-globe",
|
||||
"textClass": "d-lg-none",
|
||||
"text": "[[global:header.world]]"
|
||||
},
|
||||
{
|
||||
"route": "/users",
|
||||
"title": "[[global:header.users]]",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "nodebb",
|
||||
"license": "GPL-3.0",
|
||||
"description": "NodeBB Forum",
|
||||
"version": "3.9.0",
|
||||
"version": "4.0.0-beta.1",
|
||||
"homepage": "https://www.nodebb.org",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -50,6 +50,7 @@
|
||||
"bootswatch": "5.3.3",
|
||||
"chalk": "4.1.2",
|
||||
"chart.js": "4.4.4",
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"cli-graph": "3.2.2",
|
||||
"clipboard": "2.0.11",
|
||||
"colors": "1.4.0",
|
||||
@@ -102,15 +103,15 @@
|
||||
"nodebb-plugin-dbsearch": "6.2.5",
|
||||
"nodebb-plugin-emoji": "5.1.15",
|
||||
"nodebb-plugin-emoji-android": "4.0.0",
|
||||
"nodebb-plugin-markdown": "12.2.8",
|
||||
"nodebb-plugin-mentions": "4.4.3",
|
||||
"nodebb-plugin-ntfy": "1.7.7",
|
||||
"nodebb-plugin-markdown": "13.0.0-pre.9",
|
||||
"nodebb-plugin-mentions": "4.6.8",
|
||||
"nodebb-plugin-spam-be-gone": "2.2.2",
|
||||
"nodebb-plugin-web-push": "0.6.0",
|
||||
"nodebb-rewards-essentials": "1.0.0",
|
||||
"nodebb-theme-harmony": "1.2.70",
|
||||
"nodebb-theme-harmony": "2.0.0-pre.36",
|
||||
"nodebb-theme-lavender": "7.1.8",
|
||||
"nodebb-theme-peace": "2.2.7",
|
||||
"nodebb-theme-persona": "13.3.37",
|
||||
"nodebb-theme-persona": "14.0.0-pre.4",
|
||||
"nodebb-widget-essentials": "7.0.29",
|
||||
"nodemailer": "6.9.15",
|
||||
"nprogress": "0.2.0",
|
||||
@@ -198,4 +199,4 @@
|
||||
"url": "https://github.com/barisusakli"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
18
public/language/en-GB/activitypub.json
Normal file
18
public/language/en-GB/activitypub.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"world.name": "World",
|
||||
"world.description": "",
|
||||
"world.popular": "Popular topics",
|
||||
"world.recent": "All topics",
|
||||
"world.help": "Help",
|
||||
"no-topics": "This forum doesn't know of any other topics yet.",
|
||||
|
||||
"help.title": "What is this page?",
|
||||
"help.intro": "Welcome to your corner of the fediverse.",
|
||||
"help.fediverse": "The \"fediverse\" is a network of interconnected applications and websites that all talk to one another and whose users can see each other. This forum is federated, and can interact with that social web (or \"fediverse\"). This page is your corner of the fediverse. It consists solely of topics created by — and shared from — users <strong>you</strong> follow.",
|
||||
"help.build": "There might not be a lot of topics here to start; that's normal. You will start to see more content here over time when you start following other users.",
|
||||
"help.federating": "Likewise, if users from outside of this forum start following <em>you</em>, then your posts will start appearing on those apps and websites as well.",
|
||||
"help.next-generation": "This is the next generation of social media, start contributing today!",
|
||||
|
||||
"announcers": "Shares",
|
||||
"announcers-x": "Shares (%1)"
|
||||
}
|
||||
@@ -7,6 +7,8 @@
|
||||
"privileges": "Privileges",
|
||||
"back-to-categories": "Back to categories",
|
||||
"name": "Category Name",
|
||||
"handle": "Category Handle",
|
||||
"handle.help": "Your category handle is used as a representation of this category across other networks, similar to a username. A category handle must not match an existing username or user group.",
|
||||
"description": "Category Description",
|
||||
"bg-color": "Background Colour",
|
||||
"text-color": "Text Colour",
|
||||
@@ -37,6 +39,7 @@
|
||||
"disable": "Disable",
|
||||
"edit": "Edit",
|
||||
"analytics": "Analytics",
|
||||
"federation": "Federation",
|
||||
|
||||
"view-category": "View category",
|
||||
"set-order": "Set order",
|
||||
@@ -76,6 +79,22 @@
|
||||
"analytics.topics-daily": "<strong>Figure 3</strong> – Daily topics created in this category</small>",
|
||||
"analytics.posts-daily": "<strong>Figure 4</strong> – Daily posts made in this category</small>",
|
||||
|
||||
"federation.title": "Federation settings for \"%1\" category",
|
||||
"federation.disabled": "Federation is disabled site-wide, so category federation settings are currently unavailable.",
|
||||
"federation.disabled-cta": "Federation Settings →",
|
||||
"federation.syncing-header": "Synchronization",
|
||||
"federation.syncing-intro": "A category can follow a \"Group Actor\" via the ActivityPub protocol. If content is received from one of the actors listed below, it will be automatically added to this category.",
|
||||
"federation.syncing-caveat": "N.B. Setting up syncing here establishes a one-way synchronization. NodeBB attempts to subscribe/follow the actor, but the reverse cannot be assumed.",
|
||||
"federation.syncing-none": "This category is not currently following anybody.",
|
||||
"federation.syncing-add": "Synchronize with...",
|
||||
"federation.syncing-actorUri": "Actor",
|
||||
"federation.syncing-follow": "Follow",
|
||||
"federation.syncing-unfollow": "Unfollow",
|
||||
"federation.followers": "Remote users following this category",
|
||||
"federation.followers-handle": "Handle",
|
||||
"federation.followers-id": "ID",
|
||||
"federation.followers-none": "No followers.",
|
||||
|
||||
"alert.created": "Created",
|
||||
"alert.create-success": "Category successfully created!",
|
||||
"alert.none-active": "You have no active categories.",
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"settings/tags": "Tags",
|
||||
"settings/notifications": "Notifications",
|
||||
"settings/api": "API Access",
|
||||
"settings/activitypub": "Federation (ActivityPub)",
|
||||
"settings/sounds": "Sounds",
|
||||
"settings/social": "Social",
|
||||
"settings/cookies": "Cookies",
|
||||
|
||||
20
public/language/en-GB/admin/settings/activitypub.json
Normal file
20
public/language/en-GB/admin/settings/activitypub.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"intro-lead": "What is Federation?",
|
||||
"intro-body": "NodeBB is able to communicate with other NodeBB instances that support it. This is achieved through a protocol called <a href=\"https://activitypub.rocks/\">ActivityPub</a>. If enabled, NodeBB will also be able to communicate with other apps and websites that use ActivityPub (e.g. Mastodon, Peertube, etc.)",
|
||||
"general": "General",
|
||||
"pruning": "Content Pruning",
|
||||
"content-pruning": "Days to keep remote content",
|
||||
"content-pruning-help": "Note that remote content that has received engagement (a reply or a upvote/downvote) will be preserved. (0 for disabled)",
|
||||
"user-pruning": "Days to cache remote user accounts",
|
||||
"user-pruning-help": "Remote user accounts will only be pruned if they have no posts. Otherwise they will be re-retrieved. (0 for disabled)",
|
||||
"enabled": "Enable Federation",
|
||||
"enabled-help": "If enabled, will allow this NodeBB will be able to communicate with all Activitypub-enabled clients on the wider fediverse.",
|
||||
"allowLoopback": "Allow loopback processing",
|
||||
"allowLoopback-help": "Useful for debugging purposes only. You should probably leave this disabled.",
|
||||
|
||||
"server-filtering": "Filtering",
|
||||
"count": "This NodeBB is currently aware of <strong>%1</strong> server(s)",
|
||||
"server.filter-help": "Specify servers you would like to bar from federating with your NodeBB. Alternatively, you may opt to selectively <em>allow</em> federation with specific servers, instead. Both options are supported, although they are mutually exclusive.",
|
||||
"server.filter-help-hostname": "Enter just the instance hostname below (e.g. <code>example.org</code>), separated by line breaks.",
|
||||
"server.filter-allow-list": "Use this as an Allow List instead"
|
||||
}
|
||||
@@ -265,6 +265,7 @@
|
||||
|
||||
"topic-event-unrecognized": "Topic event '%1' unrecognized",
|
||||
|
||||
"category.handle-taken": "Category handle is already taken, please choose another.",
|
||||
"cant-set-child-as-parent": "Can't set child as parent category",
|
||||
"cant-set-self-as-parent": "Can't set self as parent category",
|
||||
|
||||
@@ -278,5 +279,12 @@
|
||||
"api.500": "An unexpected error was encountered while attempting to service your request.",
|
||||
"api.501": "The route you are trying to call is not implemented yet, please try again tomorrow",
|
||||
"api.503": "The route you are trying to call is not currently available due to a server configuration",
|
||||
"api.reauth-required": "The resource you are trying to access requires (re-)authentication."
|
||||
"api.reauth-required": "The resource you are trying to access requires (re-)authentication.",
|
||||
|
||||
"activitypub.invalid-id": "Unable to resolve the input id, likely as it is malformed.",
|
||||
"activitypub.get-failed": "Unable to retrieve the specified resource.",
|
||||
"activitypub.pubKey-not-found": "Unable to resolve public key, so payload verification cannot take place.",
|
||||
"activitypub.origin-mismatch": "The received object's origin does not match the sender's origin",
|
||||
"activitypub.actor-mismatch": "The received activity is being carried out by an actor that is different from expected.",
|
||||
"activitypub.not-implemented": "The request was denied because it or an aspect of it is not implemented by the recipient server"
|
||||
}
|
||||
|
||||
@@ -84,11 +84,17 @@
|
||||
"modal-reason-offensive": "Offensive",
|
||||
"modal-reason-other": "Other (specify below)",
|
||||
"modal-reason-custom": "Reason for reporting this content...",
|
||||
"modal-notify-remote": "Forward this report to %1",
|
||||
"modal-submit": "Submit Report",
|
||||
"modal-submit-success": "Content has been flagged for moderation.",
|
||||
|
||||
"modal-confirm-rescind": "Rescind Report?",
|
||||
|
||||
"bulk-actions": "Bulk Actions",
|
||||
"bulk-resolve": "Resolve Flag(s)",
|
||||
"confirm-purge": "Are you sure you want to permanently delete these flags?",
|
||||
"purge-cancelled": "Flag Purge Cancelled",
|
||||
"bulk-purge": "Purge Flag(s)",
|
||||
"bulk-success": "%1 flags updated",
|
||||
"flagged-timeago": "Flagged <span class=\"timeago\" title=\"%1\"></span>",
|
||||
"auto-flagged": "[Auto Flagged] Received %1 downvotes."
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
"header.navigation": "Navigation",
|
||||
"header.manage": "Manage",
|
||||
"header.drafts": "Drafts",
|
||||
"header.world": "World",
|
||||
|
||||
"notifications.loading": "Loading Notifications",
|
||||
"chats.loading": "Loading Chats",
|
||||
@@ -131,6 +132,8 @@
|
||||
"invisible": "Invisible",
|
||||
"offline": "Offline",
|
||||
|
||||
"remote-user": "This user is from outside of this forum",
|
||||
|
||||
"email": "Email",
|
||||
"language": "Language",
|
||||
|
||||
|
||||
@@ -117,6 +117,8 @@
|
||||
"composer.discard-all-drafts": "Discard all drafts",
|
||||
"composer.no-drafts": "You have no drafts",
|
||||
"composer.discard-draft-confirm": "Do you want to discard this draft?",
|
||||
"composer.remote-pid-editing": "Editing a remote post",
|
||||
"composer.remote-pid-content-immutable": "The content of remote posts cannot be edited. However, you are able change the topic title and tags.",
|
||||
|
||||
"bootbox.ok": "OK",
|
||||
"bootbox.cancel": "Cancel",
|
||||
|
||||
@@ -107,5 +107,10 @@
|
||||
"notificationType-post-queue": "When a new post is queued",
|
||||
"notificationType-new-post-flag": "When a post is flagged",
|
||||
"notificationType-new-user-flag": "When a user is flagged",
|
||||
"notificationType-new-reward": "When you earn a new reward"
|
||||
"notificationType-new-reward": "When you earn a new reward",
|
||||
|
||||
"activitypub.announce": "<strong>%1</strong> shared your post in <strong>%2</strong> to their followers.",
|
||||
"activitypub.announce-dual": "<strong>%1</strong> and <strong>%2</strong> shared your post in <strong>%3</strong> to their followers.",
|
||||
"activitypub.announce-triple": "<strong>%1</strong>, <strong>%2</strong> and <strong>%3</strong> shared your post in <strong>%4</strong> to their followers.",
|
||||
"activitypub.announce-multiple": "<strong>%1</strong>, <strong>%2</strong> and %3 others shared your post in <strong>%4</strong> to their followers."
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@
|
||||
"flags": "Flags",
|
||||
"flag-details": "Flag %1 Details",
|
||||
|
||||
"world": "World",
|
||||
|
||||
"account/edit": "Editing \"%1\"",
|
||||
"account/edit/password": "Editing password of \"%1\"",
|
||||
"account/edit/username": "Editing username of \"%1\"",
|
||||
|
||||
@@ -152,6 +152,7 @@
|
||||
"bookmarks.has-no-bookmarks": "You haven't bookmarked any posts yet.",
|
||||
|
||||
"copy-permalink": "Copy Permalink",
|
||||
"go-to-original": "View Original Post",
|
||||
|
||||
"loading-more-posts": "Loading More Posts",
|
||||
"move-topic": "Move Topic",
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
"chat-with": "Continue chat with %1",
|
||||
"new-chat-with": "Start new chat with %1",
|
||||
"flag-profile": "Flag Profile",
|
||||
"profile-flagged": "Already flagged",
|
||||
"follow": "Follow",
|
||||
"unfollow": "Unfollow",
|
||||
"more": "More",
|
||||
|
||||
@@ -8,6 +8,14 @@ CategoryObject:
|
||||
name:
|
||||
type: string
|
||||
description: The category's name/title
|
||||
handle:
|
||||
type: string
|
||||
description: |
|
||||
An URL-safe name/handle used to represent the category over federated networks (e.g. ActivityPub).
|
||||
|
||||
This value is separate from the `slug`, which is used specifically in the URL as a human-readable representation.
|
||||
|
||||
The handle is unique across-the-board between users/groups/categories.
|
||||
description:
|
||||
type: string
|
||||
description: A variable-length description of the category (usually displayed underneath the category name)
|
||||
|
||||
@@ -61,6 +61,9 @@ MessageObject:
|
||||
uid:
|
||||
type: number
|
||||
description: A user identifier
|
||||
isLocal:
|
||||
type: boolean
|
||||
description: Whether the user belongs to the local installation or not.
|
||||
username:
|
||||
type: string
|
||||
description: A friendly name for a given user account
|
||||
@@ -122,6 +125,9 @@ RoomUserList:
|
||||
uid:
|
||||
type: number
|
||||
description: A user identifier
|
||||
isLocal:
|
||||
type: boolean
|
||||
description: Whether the user belongs to the local installation or not.
|
||||
username:
|
||||
type: string
|
||||
description: A friendly name for a given user account
|
||||
|
||||
@@ -58,6 +58,9 @@ FlagObject:
|
||||
uid:
|
||||
type: number
|
||||
description: A user identifier
|
||||
isLocal:
|
||||
type: boolean
|
||||
description: Whether the user belongs to the local installation or not.
|
||||
icon:text:
|
||||
type: string
|
||||
description: A single-letter representation of a username. This is used in the
|
||||
@@ -122,6 +125,9 @@ FlagHistoryObject:
|
||||
uid:
|
||||
type: number
|
||||
description: A user identifier
|
||||
isLocal:
|
||||
type: boolean
|
||||
description: Whether the user belongs to the local installation or not.
|
||||
icon:text:
|
||||
type: string
|
||||
description: A single-letter representation of a username. This is used in the
|
||||
@@ -175,6 +181,9 @@ FlagNotesObject:
|
||||
uid:
|
||||
type: number
|
||||
description: A user identifier
|
||||
isLocal:
|
||||
type: boolean
|
||||
description: Whether the user belongs to the local installation or not.
|
||||
icon:text:
|
||||
type: string
|
||||
description: A single-letter representation of a username. This is used in the
|
||||
|
||||
@@ -7,6 +7,15 @@ PostObject:
|
||||
tid:
|
||||
type: number
|
||||
description: A topic identifier
|
||||
toPid:
|
||||
type: number
|
||||
description: The post that this post is in reply to
|
||||
nullable: true
|
||||
url:
|
||||
type: string
|
||||
description: |
|
||||
A permalink to the post content.
|
||||
For posts received via ActivityPub, it is the url of the original piece of content.
|
||||
content:
|
||||
type: string
|
||||
uid:
|
||||
@@ -31,6 +40,9 @@ PostObject:
|
||||
uid:
|
||||
type: number
|
||||
description: A user identifier
|
||||
isLocal:
|
||||
type: boolean
|
||||
description: Whether the user belongs to the local installation or not.
|
||||
username:
|
||||
type: string
|
||||
description: A friendly name for a given user account
|
||||
|
||||
@@ -35,6 +35,9 @@ TopicObject:
|
||||
uid:
|
||||
type: number
|
||||
description: A user identifier
|
||||
isLocal:
|
||||
type: boolean
|
||||
description: Whether the user belongs to the local installation or not.
|
||||
username:
|
||||
type: string
|
||||
description: A friendly name for a given user account
|
||||
|
||||
@@ -5,6 +5,9 @@ UserObject:
|
||||
type: number
|
||||
description: A user identifier
|
||||
example: 1
|
||||
isLocal:
|
||||
type: boolean
|
||||
description: Whether the user belongs to the local installation or not.
|
||||
username:
|
||||
type: string
|
||||
description: A friendly name for a given user account
|
||||
@@ -209,6 +212,9 @@ UserObjectFull:
|
||||
type: number
|
||||
description: A user identifier
|
||||
example: 1
|
||||
isLocal:
|
||||
type: boolean
|
||||
description: Whether the user belongs to the local installation or not.
|
||||
username:
|
||||
type: string
|
||||
description: A friendly name for a given user account
|
||||
@@ -449,6 +455,9 @@ UserObjectFull:
|
||||
type: boolean
|
||||
canFlag:
|
||||
type: boolean
|
||||
flagId:
|
||||
type: number
|
||||
nullable: true
|
||||
canChangePassword:
|
||||
type: boolean
|
||||
isSelf:
|
||||
@@ -537,6 +546,9 @@ UserObjectSlim:
|
||||
type: number
|
||||
description: A user identifier
|
||||
example: 1
|
||||
isLocal:
|
||||
type: boolean
|
||||
description: Whether the user belongs to the local installation or not.
|
||||
username:
|
||||
type: string
|
||||
description: A friendly name for a given user account
|
||||
@@ -625,6 +637,9 @@ UserObjectACP:
|
||||
type: number
|
||||
description: A user identifier
|
||||
example: 1
|
||||
isLocal:
|
||||
type: boolean
|
||||
description: Whether the user belongs to the local installation or not.
|
||||
username:
|
||||
type: string
|
||||
description: A friendly name for a given user account
|
||||
|
||||
@@ -92,6 +92,8 @@ paths:
|
||||
$ref: 'read/admin/settings/user.yaml'
|
||||
/api/admin/settings/post:
|
||||
$ref: 'read/admin/settings/post.yaml'
|
||||
/api/admin/settings/activitypub:
|
||||
$ref: 'read/admin/settings/activitypub.yaml'
|
||||
/api/admin/settings/advanced:
|
||||
$ref: 'read/admin/settings/advanced.yaml'
|
||||
/api/admin/manage/categories:
|
||||
@@ -100,6 +102,8 @@ paths:
|
||||
$ref: 'read/admin/manage/categories/category_id.yaml'
|
||||
"/api/admin/manage/categories/{category_id}/analytics":
|
||||
$ref: 'read/admin/manage/categories/category_id/analytics.yaml'
|
||||
"/api/admin/manage/categories/{category_id}/federation":
|
||||
$ref: 'read/admin/manage/categories/category_id/federation.yaml'
|
||||
"/api/admin/manage/privileges/{cid}":
|
||||
$ref: 'read/admin/manage/privileges/cid.yaml'
|
||||
/api/admin/manage/tags:
|
||||
@@ -326,5 +330,7 @@ paths:
|
||||
$ref: 'read/groups/slug.yaml'
|
||||
"/api/groups/{slug}/members":
|
||||
$ref: 'read/groups/slug/members.yaml'
|
||||
"/api/world":
|
||||
$ref: 'read/world.yaml'
|
||||
/api/outgoing:
|
||||
$ref: 'read/outgoing.yaml'
|
||||
@@ -0,0 +1,59 @@
|
||||
get:
|
||||
tags:
|
||||
- admin
|
||||
summary: Get category anayltics
|
||||
parameters:
|
||||
- name: category_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
example: 1
|
||||
responses:
|
||||
"200":
|
||||
description: ""
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- type: object
|
||||
properties:
|
||||
cid:
|
||||
type: number
|
||||
enabled:
|
||||
type: number
|
||||
description: Whether ActivityPub integration is enabled in site settings
|
||||
name:
|
||||
type: string
|
||||
following:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: The activity+json uri of the followed actor
|
||||
approved:
|
||||
type: boolean
|
||||
description: Whether the follow request has been accepted
|
||||
followers:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
userslug:
|
||||
type: string
|
||||
description: An URL-safe variant of the username (i.e. lower-cased, spaces removed, etc.)
|
||||
example: dragon-fruit
|
||||
picture:
|
||||
type: string
|
||||
description: A URL pointing to a picture to be used as the user's avatar
|
||||
example: 'https://images.unsplash.com/photo-1560070094-e1f2ddec4337?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=256&h=256&q=80'
|
||||
nullable: true
|
||||
uid:
|
||||
type: number
|
||||
description: A user identifier
|
||||
example: 1
|
||||
selectedCategory:
|
||||
$ref: ../../../../../components/schemas/CategoryObject.yaml#/CategoryObject
|
||||
- $ref: ../../../../../components/schemas/CommonProps.yaml#/CommonProps
|
||||
@@ -45,6 +45,9 @@ get:
|
||||
type: string
|
||||
setting:
|
||||
type: boolean
|
||||
isLocal:
|
||||
type: boolean
|
||||
description: Whether the user belongs to the local installation or not.
|
||||
default:
|
||||
type: string
|
||||
required:
|
||||
|
||||
19
public/openapi/read/admin/settings/activitypub.yaml
Normal file
19
public/openapi/read/admin/settings/activitypub.yaml
Normal file
@@ -0,0 +1,19 @@
|
||||
get:
|
||||
tags:
|
||||
- admin
|
||||
summary: Get federation (ActivityPub) settings
|
||||
responses:
|
||||
"200":
|
||||
description: ""
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- type: object
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
instanceCount:
|
||||
type: number
|
||||
description: The number of ActivityPub-enabled instances that this forum knows about.
|
||||
- $ref: ../../../components/schemas/CommonProps.yaml#/CommonProps
|
||||
@@ -151,6 +151,9 @@ get:
|
||||
uid:
|
||||
type: number
|
||||
description: A user identifier
|
||||
isLocal:
|
||||
type: boolean
|
||||
description: Whether the user belongs to the local installation or not.
|
||||
username:
|
||||
type: string
|
||||
description: A friendly name for a given user account
|
||||
@@ -220,6 +223,9 @@ get:
|
||||
uid:
|
||||
type: number
|
||||
example: 1
|
||||
isLocal:
|
||||
type: boolean
|
||||
description: Whether the user belongs to the local installation or not.
|
||||
username:
|
||||
type: string
|
||||
example: Dragon Fruit
|
||||
|
||||
@@ -178,6 +178,9 @@ get:
|
||||
`icon:text` for the user's
|
||||
auto-generated icon
|
||||
example: "#f44336"
|
||||
isLocal:
|
||||
type: boolean
|
||||
description: Whether the user belongs to the local installation or not.
|
||||
index:
|
||||
type: number
|
||||
cid:
|
||||
@@ -242,6 +245,9 @@ get:
|
||||
'icon:bgColor':
|
||||
type: string
|
||||
example: '#9c27b0'
|
||||
isLocal:
|
||||
type: boolean
|
||||
description: Whether the user belongs to the local installation or not.
|
||||
imageClass:
|
||||
type: string
|
||||
- $ref: ../components/schemas/Pagination.yaml#/Pagination
|
||||
|
||||
@@ -49,6 +49,9 @@ get:
|
||||
uid:
|
||||
type: number
|
||||
description: A user identifier
|
||||
isLocal:
|
||||
type: boolean
|
||||
description: Whether the user belongs to the local installation or not.
|
||||
icon:text:
|
||||
type: string
|
||||
description: A single-letter representation of a username. This is used in the
|
||||
|
||||
@@ -90,6 +90,9 @@ get:
|
||||
uid:
|
||||
type: number
|
||||
description: A user identifier
|
||||
isLocal:
|
||||
type: boolean
|
||||
description: Whether the user belongs to the local installation or not.
|
||||
username:
|
||||
type: string
|
||||
description: A friendly name for a given user account
|
||||
@@ -184,6 +187,8 @@ get:
|
||||
type: boolean
|
||||
downvoted:
|
||||
type: boolean
|
||||
attachments:
|
||||
type: array
|
||||
replies:
|
||||
type: object
|
||||
properties:
|
||||
@@ -428,6 +433,9 @@ get:
|
||||
type: string
|
||||
displayname:
|
||||
type: string
|
||||
isLocal:
|
||||
type: boolean
|
||||
description: Whether the user belongs to the local installation or not.
|
||||
- type: object
|
||||
description: Optional properties that may or may not be present (except for `tid`, which is always present, and is only here as a hack to pass validation)
|
||||
properties:
|
||||
|
||||
@@ -66,6 +66,9 @@ get:
|
||||
uid:
|
||||
type: number
|
||||
description: A user identifier
|
||||
isLocal:
|
||||
type: boolean
|
||||
description: Whether the user belongs to the local installation or not.
|
||||
username:
|
||||
type: string
|
||||
description: A friendly name for a given user account
|
||||
|
||||
@@ -90,6 +90,9 @@ get:
|
||||
uid:
|
||||
type: number
|
||||
description: A user identifier
|
||||
isLocal:
|
||||
type: boolean
|
||||
description: Whether the user belongs to the local installation or not.
|
||||
username:
|
||||
type: string
|
||||
description: A friendly name for a given user account
|
||||
@@ -141,6 +144,9 @@ get:
|
||||
uid:
|
||||
type: number
|
||||
description: A user identifier
|
||||
isLocal:
|
||||
type: boolean
|
||||
description: Whether the user belongs to the local installation or not.
|
||||
username:
|
||||
type: string
|
||||
description: A friendly name for a given user account
|
||||
@@ -213,6 +219,9 @@ get:
|
||||
uid:
|
||||
type: number
|
||||
description: A user identifier
|
||||
isLocal:
|
||||
type: boolean
|
||||
description: Whether the user belongs to the local installation or not.
|
||||
username:
|
||||
type: string
|
||||
description: A friendly name for a given user account
|
||||
@@ -268,6 +277,9 @@ get:
|
||||
uid:
|
||||
type: number
|
||||
description: A user identifier
|
||||
isLocal:
|
||||
type: boolean
|
||||
description: Whether the user belongs to the local installation or not.
|
||||
username:
|
||||
type: string
|
||||
description: A friendly name for a given user account
|
||||
@@ -306,6 +318,9 @@ get:
|
||||
uid:
|
||||
type: number
|
||||
description: A user identifier
|
||||
isLocal:
|
||||
type: boolean
|
||||
description: Whether the user belongs to the local installation or not.
|
||||
username:
|
||||
type: string
|
||||
description: A friendly name for a given user account
|
||||
@@ -366,6 +381,9 @@ get:
|
||||
uid:
|
||||
type: number
|
||||
description: A user identifier
|
||||
isLocal:
|
||||
type: boolean
|
||||
description: Whether the user belongs to the local installation or not.
|
||||
username:
|
||||
type: string
|
||||
description: A friendly name for a given user account
|
||||
@@ -419,6 +437,9 @@ get:
|
||||
uid:
|
||||
type: number
|
||||
description: A user identifier
|
||||
isLocal:
|
||||
type: boolean
|
||||
description: Whether the user belongs to the local installation or not.
|
||||
username:
|
||||
type: string
|
||||
description: A friendly name for a given user account
|
||||
@@ -457,6 +478,9 @@ get:
|
||||
uid:
|
||||
type: number
|
||||
description: A user identifier
|
||||
isLocal:
|
||||
type: boolean
|
||||
description: Whether the user belongs to the local installation or not.
|
||||
username:
|
||||
type: string
|
||||
description: A friendly name for a given user account
|
||||
|
||||
@@ -35,6 +35,9 @@ get:
|
||||
uid:
|
||||
type: number
|
||||
description: A user identifier
|
||||
isLocal:
|
||||
type: boolean
|
||||
description: Whether the user belongs to the local installation or not.
|
||||
username:
|
||||
type: string
|
||||
description: A friendly name for a given user account
|
||||
|
||||
165
public/openapi/read/world.yaml
Normal file
165
public/openapi/read/world.yaml
Normal file
@@ -0,0 +1,165 @@
|
||||
get:
|
||||
tags:
|
||||
- topics
|
||||
summary: Get external topics
|
||||
description: Returns a list of external topics known to the local instance
|
||||
parameters:
|
||||
- name: filter
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
example: all
|
||||
responses:
|
||||
"200":
|
||||
description: An array of topic objects sorted by timestamp.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: ../components/schemas/CategoryObject.yaml#/CategoryObject
|
||||
- type: object
|
||||
properties:
|
||||
tagWhitelist:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
topicCount:
|
||||
type: number
|
||||
topics:
|
||||
type: array
|
||||
items:
|
||||
$ref: ../components/schemas/TopicObject.yaml#/TopicObject
|
||||
# tids:
|
||||
# type: array
|
||||
# items:
|
||||
# type: number
|
||||
# canPost:
|
||||
# type: boolean
|
||||
# showSelect:
|
||||
# type: boolean
|
||||
# showTopicTools:
|
||||
# type: boolean
|
||||
# allCategoriesUrl:
|
||||
# type: string
|
||||
# selectedCategory:
|
||||
# type: object
|
||||
# properties:
|
||||
# icon:
|
||||
# type: string
|
||||
# name:
|
||||
# type: string
|
||||
# bgColor:
|
||||
# type: string
|
||||
# nullable: true
|
||||
# selectedCids:
|
||||
# type: array
|
||||
# items:
|
||||
# type: number
|
||||
selectedTag:
|
||||
type: object
|
||||
properties:
|
||||
label:
|
||||
type: string
|
||||
nullable: true
|
||||
selectedTags:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
isWatched:
|
||||
type: boolean
|
||||
isTracked:
|
||||
type: boolean
|
||||
isNotWatched:
|
||||
type: boolean
|
||||
isIgnored:
|
||||
type: boolean
|
||||
feeds:disableRSS:
|
||||
type: number
|
||||
rssFeedUrl:
|
||||
type: string
|
||||
reputation:disabled:
|
||||
type: number
|
||||
title:
|
||||
type: string
|
||||
privileges:
|
||||
type: object
|
||||
properties:
|
||||
topics:create:
|
||||
type: boolean
|
||||
topics:read:
|
||||
type: boolean
|
||||
topics:tag:
|
||||
type: boolean
|
||||
topics:schedule:
|
||||
type: boolean
|
||||
read:
|
||||
type: boolean
|
||||
posts:view_deleted:
|
||||
type: boolean
|
||||
cid:
|
||||
type: string
|
||||
uid:
|
||||
type: number
|
||||
description: A user identifier
|
||||
editable:
|
||||
type: boolean
|
||||
view_deleted:
|
||||
type: boolean
|
||||
isAdminOrMod:
|
||||
type: boolean
|
||||
# filters:
|
||||
# type: array
|
||||
# items:
|
||||
# type: object
|
||||
# properties:
|
||||
# name:
|
||||
# type: string
|
||||
# url:
|
||||
# type: string
|
||||
# selected:
|
||||
# type: boolean
|
||||
# filter:
|
||||
# type: string
|
||||
# icon:
|
||||
# type: string
|
||||
# selectedFilter:
|
||||
# type: object
|
||||
# properties:
|
||||
# name:
|
||||
# type: string
|
||||
# url:
|
||||
# type: string
|
||||
# selected:
|
||||
# type: boolean
|
||||
# filter:
|
||||
# type: string
|
||||
# icon:
|
||||
# type: string
|
||||
# terms:
|
||||
# type: array
|
||||
# items:
|
||||
# type: object
|
||||
# properties:
|
||||
# name:
|
||||
# type: string
|
||||
# url:
|
||||
# type: string
|
||||
# selected:
|
||||
# type: boolean
|
||||
# term:
|
||||
# type: string
|
||||
# selectedTerm:
|
||||
# type: object
|
||||
# properties:
|
||||
# name:
|
||||
# type: string
|
||||
# url:
|
||||
# type: string
|
||||
# selected:
|
||||
# type: boolean
|
||||
# term:
|
||||
# type: string
|
||||
- $ref: ../components/schemas/Pagination.yaml#/Pagination
|
||||
- $ref: ../components/schemas/Breadcrumbs.yaml#/Breadcrumbs
|
||||
- $ref: ../components/schemas/CommonProps.yaml#/CommonProps
|
||||
@@ -136,6 +136,8 @@ paths:
|
||||
$ref: 'write/categories/cid/privileges/privilege.yaml'
|
||||
/categories/{cid}/moderator/{uid}:
|
||||
$ref: 'write/categories/cid/moderator/uid.yaml'
|
||||
/categories/{cid}/follow:
|
||||
$ref: 'write/categories/cid/follow.yaml'
|
||||
/topics/:
|
||||
$ref: 'write/topics.yaml'
|
||||
/topics/{tid}:
|
||||
@@ -186,6 +188,10 @@ paths:
|
||||
$ref: 'write/posts/pid/voters.yaml'
|
||||
/posts/{pid}/upvoters:
|
||||
$ref: 'write/posts/pid/upvoters.yaml'
|
||||
/posts/{pid}/announcers:
|
||||
$ref: 'write/posts/pid/announcers.yaml'
|
||||
/posts/{pid}/announcers/tooltip:
|
||||
$ref: 'write/posts/pid/announcers-tooltip.yaml'
|
||||
/posts/{pid}/bookmark:
|
||||
$ref: 'write/posts/pid/bookmark.yaml'
|
||||
/posts/{pid}/diffs:
|
||||
|
||||
85
public/openapi/write/categories/cid/follow.yaml
Normal file
85
public/openapi/write/categories/cid/follow.yaml
Normal file
@@ -0,0 +1,85 @@
|
||||
put:
|
||||
tags:
|
||||
- categories
|
||||
summary: synchronize category
|
||||
description: |
|
||||
**This operation requires an enabled activitypub integration**
|
||||
|
||||
Establishes a "follow" relationship between another activitypub-enabled actor.
|
||||
Until an "accept" response is received, the synchronization will stay in a pending state.
|
||||
Upon acceptance, a one-way sync is achieved; the other actor will need to follow the same category in order to achieve full two-way synchronization.
|
||||
parameters:
|
||||
- in: path
|
||||
name: cid
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
description: a valid category id
|
||||
example: 1
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
actor:
|
||||
type: string
|
||||
description: A valid actor uri or webfinger handle
|
||||
example: 'https://example.org/foobar'
|
||||
responses:
|
||||
'200':
|
||||
description: successfully sent category synchronization request
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
$ref: ../../../components/schemas/Status.yaml#/Status
|
||||
response:
|
||||
type: object
|
||||
properties: {}
|
||||
delete:
|
||||
tags:
|
||||
- categories
|
||||
summary: unsynchronize category
|
||||
description: |
|
||||
**This operation requires an enabled activitypub integration**
|
||||
|
||||
Removes a "follow" relationship between another activitypub-enabled actor.
|
||||
Unlike the synchronization request, this does not require an acceptance from the remote end.
|
||||
|
||||
N.B. This method only severs the link for incoming content.
|
||||
parameters:
|
||||
- in: path
|
||||
name: cid
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
description: a valid category id
|
||||
example: 1
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
actor:
|
||||
type: string
|
||||
description: A valid actor uri or webfinger handle
|
||||
example: 'https://example.org/foobar'
|
||||
responses:
|
||||
'200':
|
||||
description: successfully unsynchronized category
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
$ref: ../../../components/schemas/Status.yaml#/Status
|
||||
response:
|
||||
type: object
|
||||
properties: {}
|
||||
@@ -64,6 +64,9 @@ put:
|
||||
type: number
|
||||
description: A user identifier
|
||||
example: 1
|
||||
isLocal:
|
||||
type: boolean
|
||||
description: Whether the user belongs to the local installation or not.
|
||||
username:
|
||||
type: string
|
||||
description: A friendly name for a given user account
|
||||
|
||||
@@ -64,6 +64,11 @@ get:
|
||||
type: boolean
|
||||
downvoted:
|
||||
type: boolean
|
||||
attachments:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: A sha256 hash of the attachment (tied to the corresponding entry in the database)
|
||||
put:
|
||||
tags:
|
||||
- posts
|
||||
|
||||
33
public/openapi/write/posts/pid/announcers-tooltip.yaml
Normal file
33
public/openapi/write/posts/pid/announcers-tooltip.yaml
Normal file
@@ -0,0 +1,33 @@
|
||||
get:
|
||||
tags:
|
||||
- posts
|
||||
summary: get announcers of a post
|
||||
description: This is used for getting a list of usernames for the announcers tooltip
|
||||
parameters:
|
||||
- in: path
|
||||
name: pid
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
description: a valid post id
|
||||
example: 2
|
||||
responses:
|
||||
'200':
|
||||
description: Usernames of announcers of post
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
$ref: ../../../components/schemas/Status.yaml#/Status
|
||||
response:
|
||||
type: object
|
||||
properties:
|
||||
otherCount:
|
||||
type: number
|
||||
usernames:
|
||||
type: array
|
||||
cutoff:
|
||||
type: number
|
||||
|
||||
32
public/openapi/write/posts/pid/announcers.yaml
Normal file
32
public/openapi/write/posts/pid/announcers.yaml
Normal file
@@ -0,0 +1,32 @@
|
||||
get:
|
||||
tags:
|
||||
- posts
|
||||
summary: get announcers of a post
|
||||
description: This returns the announcers of a post if the user has permission to view them
|
||||
parameters:
|
||||
- in: path
|
||||
name: pid
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
description: a valid post id
|
||||
example: 2
|
||||
responses:
|
||||
'200':
|
||||
description: Data about announcers of this post
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
$ref: ../../../components/schemas/Status.yaml#/Status
|
||||
response:
|
||||
type: object
|
||||
properties:
|
||||
announceCount:
|
||||
type: number
|
||||
announcers:
|
||||
type: array
|
||||
|
||||
|
||||
@@ -61,7 +61,12 @@ post:
|
||||
status:
|
||||
$ref: ../../components/schemas/Status.yaml#/Status
|
||||
response:
|
||||
$ref: ../../components/schemas/PostObject.yaml#/PostObject
|
||||
allOf:
|
||||
- $ref: ../../components/schemas/PostObject.yaml#/PostObject
|
||||
- type: object
|
||||
properties:
|
||||
index:
|
||||
type: number
|
||||
delete:
|
||||
tags:
|
||||
- topics
|
||||
|
||||
@@ -33,14 +33,6 @@
|
||||
#available {
|
||||
.drag-item {
|
||||
cursor: move;
|
||||
margin-right: 10px;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 20px;
|
||||
min-height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
47
public/src/admin/manage/category-federation.js
Normal file
47
public/src/admin/manage/category-federation.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import { put, del } from '../../modules/api';
|
||||
import { error } from '../../modules/alerts';
|
||||
|
||||
import * as categorySelector from '../../modules/categorySelector';
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export function init() {
|
||||
categorySelector.init($('[component="category-selector"]'), {
|
||||
onSelect: function (selectedCategory) {
|
||||
ajaxify.go('admin/manage/categories/' + selectedCategory.cid + '/federation');
|
||||
},
|
||||
showLinks: true,
|
||||
template: 'admin/partials/category/selector-dropdown-right',
|
||||
});
|
||||
|
||||
$('#site-settings').on('click', '[data-action]', function () {
|
||||
const action = $(this).attr('data-action');
|
||||
|
||||
switch (action) {
|
||||
case 'follow': {
|
||||
const actor = $('#syncing-add').val();
|
||||
|
||||
put(`/categories/${ajaxify.data.cid}/follow`, { actor })
|
||||
.then(ajaxify.refresh)
|
||||
.catch(error);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case 'unfollow': {
|
||||
const actor = $(this).attr('data-actor');
|
||||
|
||||
del(`/categories/${ajaxify.data.cid}/follow`, { actor })
|
||||
.then(ajaxify.refresh)
|
||||
.catch(error);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case 'autofill': {
|
||||
const uid = $(this).parents('[data-uid]').attr('data-uid');
|
||||
$('#syncing-add').val(uid);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -200,7 +200,7 @@ define('admin/manage/privileges', [
|
||||
ajaxify.data.privileges = { ...ajaxify.data.privileges, ...privileges };
|
||||
const tpl = parseInt(cid, 10) ? 'admin/partials/privileges/category' : 'admin/partials/privileges/global';
|
||||
const isAdminPriv = ajaxify.currentPage.endsWith('admin/manage/privileges/admin');
|
||||
app.parseAndTranslate(tpl, { privileges, isAdminPriv }).then((html) => {
|
||||
app.parseAndTranslate(tpl, { cid, privileges, isAdminPriv }).then((html) => {
|
||||
// Get currently selected filters
|
||||
const btnIndices = $('.privilege-filters button.btn-warning').map((idx, el) => $(el).index()).get();
|
||||
$('.privilege-table-container').html(html);
|
||||
@@ -228,7 +228,7 @@ define('admin/manage/privileges', [
|
||||
applyPrivileges(bannedUsersPrivs, getBannedUsersInputSelector);
|
||||
|
||||
// For rest that inherits from registered-users
|
||||
const getRegisteredUsersInputSelector = (privs, i) => `.privilege-table tr[data-group-name]:not([data-group-name="registered-users"],[data-group-name="banned-users"],[data-group-name="guests"],[data-group-name="spiders"]) td[data-privilege="${privs[i]}"] input, .privilege-table tr[data-uid]:not([data-banned]) td[data-privilege="${privs[i]}"] input`;
|
||||
const getRegisteredUsersInputSelector = (privs, i) => `.privilege-table tr[data-group-name]:not([data-group-name="registered-users"],[data-group-name="banned-users"],[data-group-name="guests"],[data-group-name="spiders"],[data-group-name="fediverse"]) td[data-privilege="${privs[i]}"] input, .privilege-table tr[data-uid]:not([data-banned]) td[data-privilege="${privs[i]}"] input`;
|
||||
const registeredUsersPrivs = getPrivilegesFromRow('registered-users');
|
||||
applyPrivileges(registeredUsersPrivs, getRegisteredUsersInputSelector);
|
||||
};
|
||||
@@ -240,7 +240,7 @@ define('admin/manage/privileges', [
|
||||
inputSelectorFn = () => `.privilege-table tr[data-banned] td[data-privilege]:nth-child(${columnNo}) input`;
|
||||
break;
|
||||
default:
|
||||
inputSelectorFn = () => `.privilege-table tr[data-group-name]:not([data-group-name="registered-users"],[data-group-name="banned-users"],[data-group-name="guests"],[data-group-name="spiders"]) td[data-privilege]:nth-child(${columnNo}) input, .privilege-table tr[data-uid]:not([data-banned]) td[data-privilege]:nth-child(${columnNo}) input`;
|
||||
inputSelectorFn = () => `.privilege-table tr[data-group-name]:not([data-group-name="registered-users"],[data-group-name="banned-users"],[data-group-name="guests"],[data-group-name="spiders"],[data-group-name="fediverse"]) td[data-privilege]:nth-child(${columnNo}) input, .privilege-table tr[data-uid]:not([data-banned]) td[data-privilege]:nth-child(${columnNo}) input`;
|
||||
}
|
||||
|
||||
const sourceChecked = getPrivilegeFromColumn(sourceGroupName, columnNo);
|
||||
|
||||
@@ -510,7 +510,7 @@ define('admin/manage/users', [
|
||||
if (confirm) {
|
||||
Promise.all(
|
||||
uids.map(
|
||||
uid => api.del(`/users/${uid}${path}`, {}).then(() => {
|
||||
uid => api.del(`/users/${encodeURIComponent(uid)}${path}`, {}).then(() => {
|
||||
if (path !== '/content') {
|
||||
removeRow(uid);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,66 @@ ajaxify.widgets = { render: render };
|
||||
if ('scrollRestoration' in history) {
|
||||
history.scrollRestoration = 'manual';
|
||||
}
|
||||
|
||||
ajaxify.check = (item) => {
|
||||
/**
|
||||
* returns:
|
||||
* true (ajaxify OK)
|
||||
* false (browser default)
|
||||
* null (no action)
|
||||
*/
|
||||
let urlObj;
|
||||
let pathname = item instanceof Element ? item.getAttribute('href') : undefined;
|
||||
try {
|
||||
urlObj = new URL(item, `${document.location.origin}${config.relative_path}`);
|
||||
if (!pathname) {
|
||||
({ pathname } = urlObj);
|
||||
}
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const internalLink = utils.isInternalURI(urlObj, window.location, config.relative_path);
|
||||
// eslint-disable-next-line no-script-url
|
||||
const hrefEmpty = href => href === undefined || href === '' || href === 'javascript:;';
|
||||
|
||||
if (item instanceof Element) {
|
||||
if (item.getAttribute('data-ajaxify') === 'false') {
|
||||
if (!internalLink) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-script-url
|
||||
if (hrefEmpty(urlObj.href) || urlObj.protocol === 'javascript:' || pathname === '#' || pathname === '') {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (internalLink) {
|
||||
// Default behaviour for rss feeds
|
||||
if (pathname.endsWith('.rss')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Default behaviour for sitemap
|
||||
if (String(pathname).startsWith(config.relative_path + '/sitemap') && pathname.endsWith('.xml')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Default behaviour for uploads and direct links to API urls
|
||||
if (['/uploads', '/assets/', '/api/'].some(function (prefix) {
|
||||
return String(pathname).startsWith(config.relative_path + prefix);
|
||||
})) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
ajaxify.go = function (url, callback, quiet) {
|
||||
// Automatically reconnect to socket and re-ajaxify on success
|
||||
if (!socket.connected && parseInt(app.user.uid, 10) >= 0) {
|
||||
@@ -512,10 +572,6 @@ $(document).ready(function () {
|
||||
});
|
||||
|
||||
function ajaxifyAnchors() {
|
||||
function hrefEmpty(href) {
|
||||
// eslint-disable-next-line no-script-url
|
||||
return href === undefined || href === '' || href === 'javascript:;';
|
||||
}
|
||||
const location = document.location || window.location;
|
||||
const rootUrl = location.protocol + '//' + (location.hostname || location.host) + (location.port ? ':' + location.port : '');
|
||||
const contentEl = document.getElementById('content');
|
||||
@@ -527,10 +583,7 @@ $(document).ready(function () {
|
||||
return;
|
||||
}
|
||||
|
||||
const $this = $(this);
|
||||
const href = $this.attr('href');
|
||||
const internalLink = utils.isInternalURI(this, window.location, config.relative_path);
|
||||
|
||||
const rootAndPath = new RegExp(`^${rootUrl}${config.relative_path}/?`);
|
||||
const process = function () {
|
||||
if (!e.ctrlKey && !e.shiftKey && !e.metaKey && e.which === 1) {
|
||||
@@ -561,52 +614,36 @@ $(document).ready(function () {
|
||||
}
|
||||
};
|
||||
|
||||
if ($this.attr('data-ajaxify') === 'false') {
|
||||
if (!internalLink) {
|
||||
return;
|
||||
}
|
||||
return e.preventDefault();
|
||||
}
|
||||
|
||||
// Default behaviour for rss feeds
|
||||
if (internalLink && href && href.endsWith('.rss')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Default behaviour for sitemap
|
||||
if (internalLink && href && String(_self.pathname).startsWith(config.relative_path + '/sitemap') && href.endsWith('.xml')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Default behaviour for uploads and direct links to API urls
|
||||
if (internalLink && ['/uploads', '/assets/', '/api/'].some(function (prefix) {
|
||||
return String(_self.pathname).startsWith(config.relative_path + prefix);
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-script-url
|
||||
if (hrefEmpty(this.href) || this.protocol === 'javascript:' || href === '#' || href === '') {
|
||||
return e.preventDefault();
|
||||
}
|
||||
|
||||
if (app.flags && app.flags.hasOwnProperty('_unsaved') && app.flags._unsaved === true) {
|
||||
if (e.ctrlKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
require(['bootbox'], function (bootbox) {
|
||||
bootbox.confirm('[[global:unsaved-changes]]', function (navigate) {
|
||||
if (navigate) {
|
||||
app.flags._unsaved = false;
|
||||
process.call(_self);
|
||||
const check = ajaxify.check(this);
|
||||
switch (check) {
|
||||
case true: {
|
||||
if (app.flags && app.flags.hasOwnProperty('_unsaved') && app.flags._unsaved === true) {
|
||||
if (e.ctrlKey) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
});
|
||||
return e.preventDefault();
|
||||
}
|
||||
|
||||
process.call(_self);
|
||||
require(['bootbox'], function (bootbox) {
|
||||
bootbox.confirm('[[global:unsaved-changes]]', function (navigate) {
|
||||
if (navigate) {
|
||||
app.flags._unsaved = false;
|
||||
process.call(_self);
|
||||
}
|
||||
});
|
||||
});
|
||||
return e.preventDefault();
|
||||
}
|
||||
|
||||
process.call(_self);
|
||||
break;
|
||||
}
|
||||
|
||||
case null: {
|
||||
e.preventDefault();
|
||||
break;
|
||||
}
|
||||
|
||||
// default is default browser behaviour
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -358,6 +358,20 @@ if (document.readyState === 'loading') {
|
||||
if (!config.useragent.isSafari && 'serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register(config.relative_path + '/service-worker.js', { scope: config.relative_path + '/' })
|
||||
.then(function () {
|
||||
navigator.serviceWorker.addEventListener('message', (event) => {
|
||||
const { action, url } = event.data;
|
||||
switch (action) {
|
||||
case 'ajaxify': {
|
||||
const check = ajaxify.check(url);
|
||||
if (check) {
|
||||
ajaxify.go(url);
|
||||
} else {
|
||||
window.location.href = url;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.info('ServiceWorker registration succeeded.');
|
||||
}).catch(function (err) {
|
||||
console.info('ServiceWorker registration failed: ', err);
|
||||
|
||||
@@ -56,6 +56,7 @@ define('forum/account/header', [
|
||||
components.get('account/delete-content').on('click', () => AccountsDelete.content(ajaxify.data.theirid));
|
||||
components.get('account/delete-all').on('click', () => AccountsDelete.purge(ajaxify.data.theirid));
|
||||
components.get('account/flag').on('click', flagAccount);
|
||||
components.get('account/already-flagged').on('click', rescindAccountFlag);
|
||||
components.get('account/block').on('click', () => toggleBlockAccount('block'));
|
||||
components.get('account/unblock').on('click', () => toggleBlockAccount('unblock'));
|
||||
};
|
||||
@@ -108,7 +109,8 @@ define('forum/account/header', [
|
||||
}
|
||||
|
||||
function toggleFollow(type) {
|
||||
api[type === 'follow' ? 'put' : 'del']('/users/' + ajaxify.data.uid + '/follow', undefined, function (err) {
|
||||
const target = isFinite(ajaxify.data.uid) ? ajaxify.data.uid : encodeURIComponent(ajaxify.data.userslug);
|
||||
api[type === 'follow' ? 'put' : 'del']('/users/' + target + '/follow', undefined, function (err) {
|
||||
if (err) {
|
||||
return alerts.error(err);
|
||||
}
|
||||
@@ -129,6 +131,18 @@ define('forum/account/header', [
|
||||
});
|
||||
}
|
||||
|
||||
function rescindAccountFlag() {
|
||||
const flagId = $(this).data('flag-id');
|
||||
require(['flags'], function (flags) {
|
||||
bootbox.confirm('[[flags:modal-confirm-rescind]]', function (confirm) {
|
||||
if (!confirm) {
|
||||
return;
|
||||
}
|
||||
flags.rescind(flagId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function toggleBlockAccount(action) {
|
||||
socket.emit('user.toggleBlock', {
|
||||
blockeeUid: ajaxify.data.uid,
|
||||
|
||||
@@ -213,13 +213,34 @@ export function handleBulkActions() {
|
||||
const subselector = e.target.closest('[data-action]');
|
||||
if (subselector) {
|
||||
const action = subselector.getAttribute('data-action');
|
||||
let confirmed;
|
||||
if (action === 'bulk-purge') {
|
||||
confirmed = new Promise((resolve, reject) => {
|
||||
bootbox.confirm('[[flags:confirm-purge]]', (confirmed) => {
|
||||
if (confirmed) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error('[[flags:purge-cancelled]]'));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
const flagIds = getSelected();
|
||||
const promises = flagIds.map((flagId) => {
|
||||
const promises = flagIds.map(async (flagId) => {
|
||||
const data = {};
|
||||
if (action === 'bulk-assign') {
|
||||
data.assignee = app.user.uid;
|
||||
} else if (action === 'bulk-mark-resolved') {
|
||||
data.state = 'resolved';
|
||||
switch (action) {
|
||||
case 'bulk-assign': {
|
||||
data.assignee = app.user.uid;
|
||||
break;
|
||||
}
|
||||
case 'bulk-mark-resolved': {
|
||||
data.state = 'resolved';
|
||||
break;
|
||||
}
|
||||
case 'bulk-purge': {
|
||||
await confirmed;
|
||||
return api.del(`/flags/${flagId}`);
|
||||
}
|
||||
}
|
||||
return api.put(`/flags/${flagId}`, data);
|
||||
});
|
||||
|
||||
@@ -302,7 +302,7 @@ define('forum/topic', [
|
||||
destroyed = false;
|
||||
|
||||
async function renderPost(pid) {
|
||||
const postData = postCache[pid] || await api.get(`/posts/${pid}/summary`);
|
||||
const postData = postCache[pid] || await api.get(`/posts/${encodeURIComponent(pid)}/summary`);
|
||||
$('#post-tooltip').remove();
|
||||
if (postData && ajaxify.data.template.topic) {
|
||||
postCache[pid] = postData;
|
||||
@@ -329,11 +329,11 @@ define('forum/topic', [
|
||||
const pathname = location.pathname;
|
||||
const validHref = href && href !== '#' && window.location.hostname === location.hostname;
|
||||
$('#post-tooltip').remove();
|
||||
const postMatch = validHref && pathname && pathname.match(/\/post\/([\d]+)/);
|
||||
const topicMatch = validHref && pathname && pathname.match(/\/topic\/([\d]+)/);
|
||||
const postMatch = validHref && pathname && pathname.match(/\/post\/([\d]+|(?:[\w_.~!$&'()*+,;=:@-]|%[\dA-F]{2})+)/);
|
||||
const topicMatch = validHref && pathname && pathname.match(/\/topic\/([\da-z-]+)/);
|
||||
if (postMatch) {
|
||||
const pid = postMatch[1];
|
||||
if (parseInt(link.parents('[component="post"]').attr('data-pid'), 10) === parseInt(pid, 10)) {
|
||||
if (encodeURIComponent(link.parents('[component="post"]').attr('data-pid')) === encodeURIComponent(pid)) {
|
||||
return; // dont render self post
|
||||
}
|
||||
|
||||
|
||||
@@ -35,10 +35,10 @@ define('forum/topic/delete-posts', [
|
||||
showPostsSelected();
|
||||
|
||||
deleteBtn.on('click', function () {
|
||||
deletePosts(deleteBtn, pid => `/posts/${pid}/state`);
|
||||
deletePosts(deleteBtn, pid => `/posts/${encodeURIComponent(pid)}/state`);
|
||||
});
|
||||
purgeBtn.on('click', function () {
|
||||
deletePosts(purgeBtn, pid => `/posts/${pid}`);
|
||||
deletePosts(purgeBtn, pid => `/posts/${encodeURIComponent(pid)}`);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@ define('forum/topic/diffs', ['api', 'bootbox', 'alerts', 'forum/topic/images'],
|
||||
return;
|
||||
}
|
||||
|
||||
api.get(`/posts/${pid}/diffs`, {}).then((data) => {
|
||||
api.get(`/posts/${encodeURIComponent(pid)}/diffs`, {}).then((data) => {
|
||||
parsePostHistory(data).then(($html) => {
|
||||
const $modal = bootbox.dialog({
|
||||
title: '[[topic:diffs.title]]',
|
||||
@@ -57,7 +57,7 @@ define('forum/topic/diffs', ['api', 'bootbox', 'alerts', 'forum/topic/images'],
|
||||
return;
|
||||
}
|
||||
|
||||
api.get(`/posts/${pid}/diffs/${since}`, {}).then((data) => {
|
||||
api.get(`/posts/${encodeURIComponent(pid)}/diffs/${since}`, {}).then((data) => {
|
||||
data.deleted = !!parseInt(data.deleted, 10);
|
||||
|
||||
app.parseAndTranslate('partials/posts_list', 'posts', {
|
||||
@@ -74,14 +74,14 @@ define('forum/topic/diffs', ['api', 'bootbox', 'alerts', 'forum/topic/images'],
|
||||
return;
|
||||
}
|
||||
|
||||
api.put(`/posts/${pid}/diffs/${since}`, {}).then(() => {
|
||||
api.put(`/posts/${encodeURIComponent(pid)}/diffs/${since}`, {}).then(() => {
|
||||
$modal.modal('hide');
|
||||
alerts.success('[[topic:diffs.post-restored]]');
|
||||
}).catch(alerts.error);
|
||||
};
|
||||
|
||||
Diffs.delete = function (pid, timestamp, $selectEl, $numberOfDiffCon) {
|
||||
api.del(`/posts/${pid}/diffs/${timestamp}`).then((data) => {
|
||||
api.del(`/posts/${encodeURIComponent(pid)}/diffs/${timestamp}`).then((data) => {
|
||||
parsePostHistory(data, 'diffs').then(($html) => {
|
||||
$selectEl.empty().append($html);
|
||||
$selectEl.trigger('change');
|
||||
|
||||
@@ -71,7 +71,7 @@ define('forum/topic/events', [
|
||||
|
||||
function updatePostVotesAndUserReputation(data) {
|
||||
const votes = $('[data-pid="' + data.post.pid + '"] [component="post/vote-count"]').filter(function (index, el) {
|
||||
return parseInt($(el).closest('[data-pid]').attr('data-pid'), 10) === parseInt(data.post.pid, 10);
|
||||
return $(el).closest('[data-pid]').attr('data-pid') === String(data.post.pid);
|
||||
});
|
||||
const reputationElements = $('.reputation[data-uid="' + data.post.uid + '"]');
|
||||
votes.html(data.post.votes).attr('data-votes', data.post.votes);
|
||||
@@ -101,15 +101,15 @@ define('forum/topic/events', [
|
||||
}
|
||||
|
||||
function onPostEdited(data) {
|
||||
if (!data || !data.post || parseInt(data.post.tid, 10) !== parseInt(ajaxify.data.tid, 10)) {
|
||||
if (!data || !data.post || String(data.post.tid) !== String(ajaxify.data.tid)) {
|
||||
return;
|
||||
}
|
||||
const editedPostEl = components.get('post/content', data.post.pid).filter(function (index, el) {
|
||||
return parseInt($(el).closest('[data-pid]').attr('data-pid'), 10) === parseInt(data.post.pid, 10);
|
||||
return String($(el).closest('[data-pid]').attr('data-pid')) === String(data.post.pid);
|
||||
});
|
||||
const postContainer = $(`[data-pid="${data.post.pid}"]`);
|
||||
const editorEl = postContainer.find('[component="post/editor"]').filter(function (index, el) {
|
||||
return parseInt($(el).closest('[data-pid]').attr('data-pid'), 10) === parseInt(data.post.pid, 10);
|
||||
return String($(el).closest('[data-pid]').attr('data-pid')) === String(data.post.pid);
|
||||
});
|
||||
const topicTitle = components.get('topic/title');
|
||||
const navbarTitle = components.get('navbar/title').find('span');
|
||||
@@ -225,10 +225,10 @@ define('forum/topic/events', [
|
||||
function togglePostVote(data) {
|
||||
const post = $('[data-pid="' + data.post.pid + '"]');
|
||||
post.find('[component="post/upvote"]').filter(function (index, el) {
|
||||
return parseInt($(el).closest('[data-pid]').attr('data-pid'), 10) === parseInt(data.post.pid, 10);
|
||||
return $(el).closest('[data-pid]').attr('data-pid') === String(data.post.pid);
|
||||
}).toggleClass('upvoted', data.upvote);
|
||||
post.find('[component="post/downvote"]').filter(function (index, el) {
|
||||
return parseInt($(el).closest('[data-pid]').attr('data-pid'), 10) === parseInt(data.post.pid, 10);
|
||||
return $(el).closest('[data-pid]').attr('data-pid') === String(data.post.pid);
|
||||
}).toggleClass('downvoted', data.downvote);
|
||||
}
|
||||
|
||||
|
||||
@@ -141,7 +141,7 @@ define('forum/topic/move-post', [
|
||||
return;
|
||||
}
|
||||
|
||||
Promise.all(data.pids.map(pid => api.put(`/posts/${pid}/move`, {
|
||||
Promise.all(data.pids.map(pid => api.put(`/posts/${encodeURIComponent(pid)}/move`, {
|
||||
tid: data.tid,
|
||||
}))).then(() => {
|
||||
data.pids.forEach(function (pid) {
|
||||
|
||||
@@ -141,6 +141,10 @@ define('forum/topic/postTools', [
|
||||
votes.showVotes(getData($(this), 'data-pid'));
|
||||
});
|
||||
|
||||
postContainer.on('click', '[component="post/announce-count"]', function () {
|
||||
votes.showAnnouncers(getData($(this), 'data-pid'));
|
||||
});
|
||||
|
||||
postContainer.on('click', '[component="post/flag"]', function () {
|
||||
const pid = getData($(this), 'data-pid');
|
||||
require(['flags'], function (flags) {
|
||||
@@ -151,6 +155,18 @@ define('forum/topic/postTools', [
|
||||
});
|
||||
});
|
||||
|
||||
postContainer.on('click', '[component="post/already-flagged"]', function () {
|
||||
const flagId = $(this).data('flag-id');
|
||||
require(['flags'], function (flags) {
|
||||
bootbox.confirm('[[flags:modal-confirm-rescind]]', function (confirm) {
|
||||
if (!confirm) {
|
||||
return;
|
||||
}
|
||||
flags.rescind(flagId);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
postContainer.on('click', '[component="post/flagUser"]', function () {
|
||||
const uid = getData($(this), 'data-uid');
|
||||
require(['flags'], function (flags) {
|
||||
@@ -322,7 +338,7 @@ define('forum/topic/postTools', [
|
||||
return quote(selectedNode.text);
|
||||
}
|
||||
|
||||
const { content } = await api.get(`/posts/${toPid}/raw`);
|
||||
const { content } = await api.get(`/posts/${encodeURIComponent(toPid)}/raw`);
|
||||
quote(content);
|
||||
});
|
||||
}
|
||||
@@ -352,7 +368,7 @@ define('forum/topic/postTools', [
|
||||
function bookmarkPost(button, pid) {
|
||||
const method = button.attr('data-bookmarked') === 'false' ? 'put' : 'del';
|
||||
|
||||
api[method](`/posts/${pid}/bookmark`, undefined, function (err) {
|
||||
api[method](`/posts/${encodeURIComponent(pid)}/bookmark`, undefined, function (err) {
|
||||
if (err) {
|
||||
return alerts.error(err);
|
||||
}
|
||||
@@ -421,7 +437,7 @@ define('forum/topic/postTools', [
|
||||
|
||||
const route = action === 'purge' ? '' : '/state';
|
||||
const method = action === 'restore' ? 'put' : 'del';
|
||||
api[method](`/posts/${pid}${route}`).catch(alerts.error);
|
||||
api[method](`/posts/${encodeURIComponent(pid)}${route}`).catch(alerts.error);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -256,7 +256,7 @@ define('forum/topic/posts', [
|
||||
const after = parseInt(afterEl.attr('data-index'), 10) || 0;
|
||||
|
||||
const tid = ajaxify.data.tid;
|
||||
if (!utils.isNumber(tid) || !utils.isNumber(after) || (direction < 0 && components.get('post', 'index', 0).length)) {
|
||||
if (!utils.isNumber(after) || (direction < 0 && components.get('post', 'index', 0).length)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ define('forum/topic/replies', ['forum/topic/posts', 'hooks', 'alerts', 'api'], f
|
||||
.removeClass('fa-chevron-down')
|
||||
.addClass('fa-spin fa-spinner');
|
||||
|
||||
api.get(`/posts/${pid}/replies`, {}, function (err, { replies }) {
|
||||
api.get(`/posts/${encodeURIComponent(pid)}/replies`, {}, function (err, { replies }) {
|
||||
const postData = replies;
|
||||
open.removeAttr('loading')
|
||||
.attr('loaded', '1')
|
||||
|
||||
@@ -13,6 +13,9 @@ define('forum/topic/votes', [
|
||||
components.get('topic').on('mouseenter', '[data-pid] [component="post/vote-count"]', loadDataAndCreateTooltip);
|
||||
components.get('topic').on('mouseleave', '[data-pid] [component="post/vote-count"]', destroyTooltip);
|
||||
}
|
||||
|
||||
components.get('topic').on('mouseenter', '[data-pid] [component="post/announce-count"]', loadDataAndCreateTooltip);
|
||||
components.get('topic').on('mouseleave', '[data-pid] [component="post/announce-count"]', destroyTooltip);
|
||||
};
|
||||
|
||||
function canSeeUpVotes() {
|
||||
@@ -50,8 +53,11 @@ define('forum/topic/votes', [
|
||||
tooltip.dispose();
|
||||
$this.attr('title', '');
|
||||
}
|
||||
const path = $this.attr('component') === 'post/vote-count' ?
|
||||
`/posts/${encodeURIComponent(pid)}/upvoters` :
|
||||
`/posts/${encodeURIComponent(pid)}/announcers/tooltip`;
|
||||
|
||||
api.get(`/posts/${pid}/upvoters`, {}, function (err, data) {
|
||||
api.get(path, {}, function (err, data) {
|
||||
if (err) {
|
||||
return alerts.error(err);
|
||||
}
|
||||
@@ -93,7 +99,7 @@ define('forum/topic/votes', [
|
||||
|
||||
const method = currentState ? 'del' : 'put';
|
||||
const pid = post.attr('data-pid');
|
||||
api[method](`/posts/${pid}/vote`, {
|
||||
api[method](`/posts/${encodeURIComponent(pid)}/vote`, {
|
||||
delta: delta,
|
||||
}, function (err) {
|
||||
if (err) {
|
||||
@@ -117,7 +123,7 @@ define('forum/topic/votes', [
|
||||
if (!canSeeVotes()) {
|
||||
return;
|
||||
}
|
||||
api.get(`/posts/${pid}/voters`, {}, function (err, data) {
|
||||
api.get(`/posts/${encodeURIComponent(pid)}/voters`, {}, function (err, data) {
|
||||
if (err) {
|
||||
return alerts.error(err);
|
||||
}
|
||||
@@ -139,6 +145,24 @@ define('forum/topic/votes', [
|
||||
});
|
||||
};
|
||||
|
||||
Votes.showAnnouncers = async function (pid) {
|
||||
const data = await api.get(`/posts/${encodeURIComponent(pid)}/announcers`, {})
|
||||
.catch(err => alerts.error(err));
|
||||
|
||||
const html = await app.parseAndTranslate('modals/announcers', data);
|
||||
const dialog = bootbox.dialog({
|
||||
title: `[[activitypub:announcers-x, ${data.announceCount}]]`,
|
||||
message: html,
|
||||
className: 'announce-modal',
|
||||
show: true,
|
||||
onEscape: true,
|
||||
backdrop: true,
|
||||
});
|
||||
|
||||
dialog.on('click', function () {
|
||||
dialog.modal('hide');
|
||||
});
|
||||
};
|
||||
|
||||
return Votes;
|
||||
});
|
||||
|
||||
70
public/src/client/world.js
Normal file
70
public/src/client/world.js
Normal file
@@ -0,0 +1,70 @@
|
||||
'use strict';
|
||||
|
||||
define('forum/world', ['topicList', 'sort', 'hooks', 'alerts', 'api', 'bootbox'], function (topicList, sort, hooks, alerts, api, bootbox) {
|
||||
const World = {};
|
||||
|
||||
World.init = function () {
|
||||
app.enterRoom('world');
|
||||
topicList.init('world');
|
||||
|
||||
sort.handleSort('categoryTopicSort', 'world');
|
||||
|
||||
handleIgnoreWatch(-1);
|
||||
handleHelp();
|
||||
|
||||
hooks.fire('action:topics.loaded', { topics: ajaxify.data.topics });
|
||||
hooks.fire('action:category.loaded', { cid: ajaxify.data.cid });
|
||||
};
|
||||
|
||||
function handleIgnoreWatch(cid) {
|
||||
$('[component="category/watching"], [component="category/tracking"], [component="category/ignoring"], [component="category/notwatching"]').on('click', function () {
|
||||
const $this = $(this);
|
||||
const state = $this.attr('data-state');
|
||||
|
||||
api.put(`/categories/${cid}/watch`, { state }, (err) => {
|
||||
if (err) {
|
||||
return alerts.error(err);
|
||||
}
|
||||
|
||||
$('[component="category/watching/menu"]').toggleClass('hidden', state !== 'watching');
|
||||
$('[component="category/watching/check"]').toggleClass('fa-check', state === 'watching');
|
||||
|
||||
$('[component="category/tracking/menu"]').toggleClass('hidden', state !== 'tracking');
|
||||
$('[component="category/tracking/check"]').toggleClass('fa-check', state === 'tracking');
|
||||
|
||||
$('[component="category/notwatching/menu"]').toggleClass('hidden', state !== 'notwatching');
|
||||
$('[component="category/notwatching/check"]').toggleClass('fa-check', state === 'notwatching');
|
||||
|
||||
$('[component="category/ignoring/menu"]').toggleClass('hidden', state !== 'ignoring');
|
||||
$('[component="category/ignoring/check"]').toggleClass('fa-check', state === 'ignoring');
|
||||
|
||||
alerts.success('[[category:' + state + '.message]]');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function handleHelp() {
|
||||
const trigger = document.getElementById('world-help');
|
||||
if (!trigger) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = [
|
||||
'<p class="lead">[[activitypub:help.intro]]</p>',
|
||||
'<p>[[activitypub:help.fediverse]]</p>',
|
||||
'<p>[[activitypub:help.build]]</p>',
|
||||
'<p>[[activitypub:help.federating]]</p>',
|
||||
'<p>[[activitypub:help.next-generation]]</p>',
|
||||
];
|
||||
|
||||
trigger.addEventListener('click', () => {
|
||||
bootbox.dialog({
|
||||
title: '[[activitypub:help.title]]',
|
||||
message: content.join('\n'),
|
||||
size: 'large',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return World;
|
||||
});
|
||||
@@ -62,6 +62,11 @@ async function xhr(options) {
|
||||
|
||||
const res = await fetch(url, options);
|
||||
const { headers } = res;
|
||||
|
||||
if (headers.get('x-redirect')) {
|
||||
return xhr({ url: headers.get('x-redirect'), ...options });
|
||||
}
|
||||
|
||||
const contentType = headers.get('content-type');
|
||||
const isJSON = contentType && contentType.startsWith('application/json');
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ define('flags', ['hooks', 'components', 'api', 'alerts'], function (hooks, compo
|
||||
let flagReason;
|
||||
|
||||
Flag.showFlagModal = function (data) {
|
||||
data.remote = URL.canParse(data.id) ? new URL(data.id).hostname : false;
|
||||
|
||||
app.parseAndTranslate('modals/flag', data, function (html) {
|
||||
flagModal = html;
|
||||
flagModal.on('hidden.bs.modal', function () {
|
||||
@@ -35,18 +37,21 @@ define('flags', ['hooks', 'components', 'api', 'alerts'], function (hooks, compo
|
||||
if (selected.attr('id') === 'flag-reason-other') {
|
||||
reason = flagReason.val();
|
||||
}
|
||||
createFlag(data.type, data.id, reason);
|
||||
const notifyRemote = $('input[name="flag-notify-remote"]').is(':checked');
|
||||
createFlag(data.type, data.id, reason, notifyRemote);
|
||||
});
|
||||
|
||||
flagModal.on('click', '#flag-reason-other', function () {
|
||||
flagReason.focus();
|
||||
});
|
||||
|
||||
|
||||
flagModal.modal('show');
|
||||
hooks.fire('action:flag.showModal', {
|
||||
modalEl: flagModal,
|
||||
type: data.type,
|
||||
id: data.id,
|
||||
remote: data.remote,
|
||||
});
|
||||
|
||||
flagModal.find('#flag-reason-custom').on('keyup blur change', checkFlagButtonEnable);
|
||||
@@ -62,11 +67,26 @@ define('flags', ['hooks', 'components', 'api', 'alerts'], function (hooks, compo
|
||||
}).catch(alerts.error);
|
||||
};
|
||||
|
||||
function createFlag(type, id, reason) {
|
||||
|
||||
Flag.rescind = function (flagId) {
|
||||
api.del(`/flags/${flagId}/report`).then(() => {
|
||||
alerts.success('[[flags:report-rescinded]]');
|
||||
hooks.fire('action:flag.rescinded', { flagId: flagId });
|
||||
}).catch(alerts.error);
|
||||
};
|
||||
|
||||
Flag.purge = function (flagId) {
|
||||
api.del(`/flags/${flagId}`).then(() => {
|
||||
alerts.success('[[flags:purged]]');
|
||||
hooks.fire('action:flag.purged', { flagId: flagId });
|
||||
}).catch(alerts.error);
|
||||
};
|
||||
|
||||
function createFlag(type, id, reason, notifyRemote = false) {
|
||||
if (!type || !id || !reason) {
|
||||
return;
|
||||
}
|
||||
const data = { type: type, id: id, reason: reason };
|
||||
const data = { type: type, id: id, reason: reason, notifyRemote: notifyRemote };
|
||||
api.post('/flags', data, function (err, flagId) {
|
||||
if (err) {
|
||||
return alerts.error(err);
|
||||
|
||||
@@ -28,6 +28,7 @@ module.exports = function (utils, Benchpress, relative_path) {
|
||||
generateWroteReplied,
|
||||
generateRepliedTo,
|
||||
generateWrote,
|
||||
encodeURIComponent: _encodeURIComponent,
|
||||
isoTimeToLocaleString,
|
||||
shouldHideReplyContainer,
|
||||
humanReadableNumber,
|
||||
@@ -176,7 +177,7 @@ module.exports = function (utils, Benchpress, relative_path) {
|
||||
return '';
|
||||
}
|
||||
|
||||
function spawnPrivilegeStates(member, privileges, types) {
|
||||
function spawnPrivilegeStates(cid, member, privileges, types) {
|
||||
const states = [];
|
||||
for (const priv in privileges) {
|
||||
if (privileges.hasOwnProperty(priv)) {
|
||||
@@ -191,15 +192,20 @@ module.exports = function (utils, Benchpress, relative_path) {
|
||||
const guestDisabled = ['groups:moderate', 'groups:posts:upvote', 'groups:posts:downvote', 'groups:local:login', 'groups:group:create'];
|
||||
const spidersEnabled = ['groups:find', 'groups:read', 'groups:topics:read', 'groups:view:users', 'groups:view:tags', 'groups:view:groups'];
|
||||
const globalModDisabled = ['groups:moderate'];
|
||||
let fediverseEnabled = ['groups:view:users', 'groups:find', 'groups:read', 'groups:topics:read', 'groups:topics:create', 'groups:topics:reply', 'groups:topics:tag', 'groups:posts:edit', 'groups:posts:history', 'groups:posts:delete', 'groups:posts:upvote', 'groups:posts:downvote', 'groups:topics:delete'];
|
||||
if (cid === -1) {
|
||||
fediverseEnabled = fediverseEnabled.slice(3);
|
||||
}
|
||||
const disabled =
|
||||
(member === 'guests' && (guestDisabled.includes(priv.name) || priv.name.startsWith('groups:admin:'))) ||
|
||||
(member === 'spiders' && !spidersEnabled.includes(priv.name)) ||
|
||||
(member === 'fediverse' && !fediverseEnabled.includes(priv.name)) ||
|
||||
(member === 'Global Moderators' && globalModDisabled.includes(priv.name));
|
||||
|
||||
return `
|
||||
<td data-privilege="${priv.name}" data-value="${priv.state}" data-type="${priv.type}">
|
||||
<div class="form-check text-center">
|
||||
<input class="form-check-input float-none" autocomplete="off" type="checkbox"${(priv.state ? ' checked' : '')}${(disabled ? ' disabled="disabled"' : '')} />
|
||||
<input class="form-check-input float-none${(disabled ? ' d-none"' : '')}" autocomplete="off" type="checkbox"${(priv.state ? ' checked' : '')}${(disabled ? ' disabled="disabled" aria-diabled="true"' : '')} />
|
||||
</div>
|
||||
</td>
|
||||
`;
|
||||
@@ -334,13 +340,17 @@ module.exports = function (utils, Benchpress, relative_path) {
|
||||
post.parent.displayname : '[[global:guest]]';
|
||||
const isBeforeCutoff = post.timestamp < (Date.now() - (timeagoCutoff * oneDayInMs));
|
||||
const langSuffix = isBeforeCutoff ? 'on' : 'ago';
|
||||
return `[[topic:replied-to-user-${langSuffix}, ${post.toPid}, ${relative_path}/post/${post.toPid}, ${displayname}, ${relative_path}/post/${post.pid}, ${post.timestampISO}]]`;
|
||||
return `[[topic:replied-to-user-${langSuffix}, ${post.toPid}, ${relative_path}/post/${encodeURIComponent(post.toPid)}, ${displayname}, ${relative_path}/post/${encodeURIComponent(post.pid)}, ${post.timestampISO}]]`;
|
||||
}
|
||||
|
||||
function generateWrote(post, timeagoCutoff) {
|
||||
const isBeforeCutoff = post.timestamp < (Date.now() - (timeagoCutoff * oneDayInMs));
|
||||
const langSuffix = isBeforeCutoff ? 'on' : 'ago';
|
||||
return `[[topic:wrote-${langSuffix}, ${relative_path}/post/${post.pid}, ${post.timestampISO}]]`;
|
||||
return `[[topic:wrote-${langSuffix}, ${relative_path}/post/${encodeURIComponent(post.pid)}, ${post.timestampISO}]]`;
|
||||
}
|
||||
|
||||
function _encodeURIComponent(value) {
|
||||
return encodeURIComponent(value);
|
||||
}
|
||||
|
||||
function isoTimeToLocaleString(isoTime, locale = 'en-GB') {
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
window.slugify = factory(XRegExp);
|
||||
}
|
||||
}(function (XRegExp) {
|
||||
const invalidUnicodeChars = XRegExp('[^\\p{L}\\s\\d\\-_]', 'g');
|
||||
const invalidLatinChars = /[^\w\s\d\-_]/g;
|
||||
const invalidUnicodeChars = XRegExp('[^\\p{L}\\s\\d\\-_@.]', 'g');
|
||||
const invalidLatinChars = /[^\w\s\d\-_@.]/g;
|
||||
const trimRegex = /^\s+|\s+$/g;
|
||||
const collapseWhitespace = /\s+/g;
|
||||
const collapseDash = /-+/g;
|
||||
|
||||
@@ -144,7 +144,7 @@ define('tagFilter', ['hooks', 'alerts', 'bootstrap'], function (hooks, alerts, b
|
||||
|
||||
function loadList(query, callback) {
|
||||
let cids = null;
|
||||
if (ajaxify.data.template.category) {
|
||||
if (ajaxify.data.template.category || ajaxify.data.template.world) {
|
||||
cids = [ajaxify.data.cid];
|
||||
// selectedCids is avaiable on /recent, /unread, /popular etc.
|
||||
} else if (Array.isArray(ajaxify.data.selectedCids) && ajaxify.data.selectedCids.length) {
|
||||
|
||||
@@ -7,7 +7,7 @@ define('topicThumbs', [
|
||||
|
||||
Thumbs.get = id => api.get(`/topics/${id}/thumbs`, {});
|
||||
|
||||
Thumbs.getByPid = pid => api.get(`/posts/${pid}`, {}).then(post => Thumbs.get(post.tid));
|
||||
Thumbs.getByPid = pid => api.get(`/posts/${encodeURIComponent(pid)}`, {}).then(post => Thumbs.get(post.tid));
|
||||
|
||||
Thumbs.delete = (id, path) => api.del(`/topics/${id}/thumbs`, {
|
||||
path: path,
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
'use strict';
|
||||
|
||||
self.addEventListener('install', () => {
|
||||
// Register self as the primary service worker
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
// Take responsibility over existing clients from old service worker
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', function (event) {
|
||||
// This is the code that ignores post requests
|
||||
// https://github.com/NodeBB/NodeBB/issues/9151
|
||||
@@ -17,3 +27,65 @@ self.addEventListener('fetch', function (event) {
|
||||
return response;
|
||||
}));
|
||||
});
|
||||
|
||||
/**
|
||||
* The following code is used by nodebb-plugin-web-push
|
||||
* There is a very strong argument to be made that this is plugin-specific
|
||||
* code and does not belong in core.
|
||||
*
|
||||
* Additional R&D is required to determine how to allow plugins to inject
|
||||
* code into the service worker.
|
||||
*/
|
||||
|
||||
// Register event listener for the 'push' event.
|
||||
self.addEventListener('push', function (event) {
|
||||
// Keep the service worker alive until the notification is created.
|
||||
const { title, body, tag, data } = event.data.json();
|
||||
|
||||
if (title && body) {
|
||||
const icon = data.icon;
|
||||
delete data.icon;
|
||||
const badge = data.badge;
|
||||
delete data.badge;
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(title, { body, tag, data, icon, badge })
|
||||
);
|
||||
} else if (tag) {
|
||||
event.waitUntil(
|
||||
self.registration.getNotifications({ tag }).then((notifications) => {
|
||||
notifications.forEach((notification) => {
|
||||
notification.close();
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
let target;
|
||||
if (event.notification.data && event.notification.data.url) {
|
||||
target = new URL(event.notification.data.url);
|
||||
}
|
||||
|
||||
// This looks to see if the current is already open and focuses if it is
|
||||
event.waitUntil(
|
||||
self.clients
|
||||
.matchAll({ type: 'window' })
|
||||
.then((clientList) => {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const client of clientList) {
|
||||
const { hostname } = new URL(client.url);
|
||||
if (target && hostname === target.hostname && 'focus' in client) {
|
||||
client.postMessage({
|
||||
action: 'ajaxify',
|
||||
url: target.pathname,
|
||||
});
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
if (self.clients.openWindow) return self.clients.openWindow(target.pathname);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
323
src/activitypub/actors.js
Normal file
323
src/activitypub/actors.js
Normal file
@@ -0,0 +1,323 @@
|
||||
'use strict';
|
||||
|
||||
const nconf = require('nconf');
|
||||
const winston = require('winston');
|
||||
|
||||
const db = require('../database');
|
||||
const meta = require('../meta');
|
||||
const batch = require('../batch');
|
||||
const user = require('../user');
|
||||
const utils = require('../utils');
|
||||
const TTLCache = require('../cache/ttl');
|
||||
|
||||
const failedWebfingerCache = TTLCache({
|
||||
max: 5000,
|
||||
ttl: 1000 * 60 * 10, // 10 minutes
|
||||
});
|
||||
|
||||
const activitypub = module.parent.exports;
|
||||
|
||||
const Actors = module.exports;
|
||||
|
||||
Actors.assert = async (ids, options = {}) => {
|
||||
/**
|
||||
* Ensures that the passed in ids or webfinger handles are stored in database.
|
||||
* Options:
|
||||
* - update: boolean, forces re-fetch/process of the resolved id
|
||||
* Return one of:
|
||||
* - An array of newly processed ids
|
||||
* - false: if input incorrect (or webfinger handle cannot resolve)
|
||||
* - true: no new IDs processed; all passed-in IDs present.
|
||||
*/
|
||||
|
||||
// Handle single values
|
||||
if (!Array.isArray(ids)) {
|
||||
ids = [ids];
|
||||
}
|
||||
if (!ids.length) {
|
||||
return false;
|
||||
}
|
||||
// Existance in failure cache is automatic assertion failure
|
||||
if (ids.some(id => failedWebfingerCache.has(id))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter out uids if passed in
|
||||
ids = ids.filter(id => !utils.isNumber(id));
|
||||
|
||||
// Translate webfinger handles to uris
|
||||
const hostMap = new Map();
|
||||
ids = (await Promise.all(ids.map(async (id) => {
|
||||
const originalId = id;
|
||||
if (activitypub.helpers.isWebfinger(id)) {
|
||||
const host = id.replace(/^(acct:|@)/, '').split('@')[1];
|
||||
if (host === nconf.get('url_parsed').host) { // do not assert loopback ids
|
||||
return 'loopback';
|
||||
}
|
||||
|
||||
({ actorUri: id } = await activitypub.helpers.query(id));
|
||||
hostMap.set(id, host);
|
||||
}
|
||||
// ensure the final id is a valid URI
|
||||
if (!id || !activitypub.helpers.isUri(id)) {
|
||||
failedWebfingerCache.set(originalId, true);
|
||||
return;
|
||||
}
|
||||
return id;
|
||||
})));
|
||||
|
||||
// Webfinger failures = assertion failure
|
||||
if (!ids.every(Boolean)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter out loopback uris
|
||||
ids = ids.filter(uri => uri !== 'loopback' && new URL(uri).host !== nconf.get('url_parsed').host);
|
||||
|
||||
// Only assert those who haven't been seen recently (configurable), unless update flag passed in (force refresh)
|
||||
if (!options.update) {
|
||||
const upperBound = Date.now() - (1000 * 60 * 60 * 24 * meta.config.activitypubUserPruneDays);
|
||||
const lastCrawled = await db.sortedSetScores('usersRemote:lastCrawled', ids.map(id => ((typeof id === 'object' && id.hasOwnProperty('id')) ? id.id : id)));
|
||||
ids = ids.filter((id, idx) => {
|
||||
const timestamp = lastCrawled[idx];
|
||||
return !timestamp || timestamp < upperBound;
|
||||
});
|
||||
}
|
||||
|
||||
if (!ids.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// winston.verbose(`[activitypub/actors] Asserting ${ids.length} actor(s)`);
|
||||
|
||||
// NOTE: MAKE SURE EVERY DB ADDITION HAS A CORRESPONDING REMOVAL IN ACTORS.REMOVE!
|
||||
|
||||
const urlMap = new Map();
|
||||
const followersUrlMap = new Map();
|
||||
const pubKeysMap = new Map();
|
||||
let actors = await Promise.all(ids.map(async (id) => {
|
||||
try {
|
||||
// winston.verbose(`[activitypub/actors] Processing ${id}`);
|
||||
const actor = (typeof id === 'object' && id.hasOwnProperty('id')) ? id : await activitypub.get('uid', 0, id, { cache: process.env.CI === 'true' });
|
||||
if (
|
||||
!activitypub._constants.acceptableActorTypes.has(actor.type) ||
|
||||
!activitypub._constants.requiredActorProps.every(prop => actor.hasOwnProperty(prop))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Follow counts
|
||||
try {
|
||||
const [followers, following] = await Promise.all([
|
||||
actor.followers ? activitypub.get('uid', 0, actor.followers) : { totalItems: 0 },
|
||||
actor.following ? activitypub.get('uid', 0, actor.following) : { totalItems: 0 },
|
||||
]);
|
||||
actor.followerCount = followers.totalItems;
|
||||
actor.followingCount = following.totalItems;
|
||||
} catch (e) {
|
||||
// no action required
|
||||
// winston.verbose(`[activitypub/actor.assert] Unable to retrieve follower counts for ${actor.id}`);
|
||||
}
|
||||
|
||||
// Save url for backreference
|
||||
const url = Array.isArray(actor.url) ? actor.url.shift() : actor.url;
|
||||
if (url && url !== actor.id) {
|
||||
urlMap.set(url, actor.id);
|
||||
}
|
||||
|
||||
// Save followers url for backreference
|
||||
if (actor.hasOwnProperty('followers') && activitypub.helpers.isUri(actor.followers)) {
|
||||
followersUrlMap.set(actor.followers, actor.id);
|
||||
}
|
||||
|
||||
// Public keys
|
||||
pubKeysMap.set(actor.id, actor.publicKey);
|
||||
|
||||
return actor;
|
||||
} catch (e) {
|
||||
if (e.code === 'ap_get_410') {
|
||||
const exists = await user.exists(id);
|
||||
if (exists) {
|
||||
await user.deleteAccount(id);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}));
|
||||
actors = actors.filter(Boolean); // remove unresolvable actors
|
||||
|
||||
// Build userData object for storage
|
||||
const profiles = (await activitypub.mocks.profile(actors, hostMap)).filter(Boolean);
|
||||
const now = Date.now();
|
||||
|
||||
const bulkSet = profiles.reduce((memo, profile) => {
|
||||
const key = `userRemote:${profile.uid}`;
|
||||
memo.push([key, profile], [`${key}:keys`, pubKeysMap.get(profile.uid)]);
|
||||
return memo;
|
||||
}, []);
|
||||
if (urlMap.size) {
|
||||
bulkSet.push(['remoteUrl:uid', Object.fromEntries(urlMap)]);
|
||||
}
|
||||
if (followersUrlMap.size) {
|
||||
bulkSet.push(['followersUrl:uid', Object.fromEntries(followersUrlMap)]);
|
||||
}
|
||||
|
||||
const exists = await db.isSortedSetMembers('usersRemote:lastCrawled', profiles.map(p => p.uid));
|
||||
const uidsForCurrent = profiles.map((p, idx) => (exists[idx] ? p.uid : 0));
|
||||
const current = await user.getUsersFields(uidsForCurrent, ['username', 'fullname']);
|
||||
const queries = profiles.reduce((memo, profile, idx) => {
|
||||
const { username, fullname } = current[idx];
|
||||
|
||||
if (username !== profile.username) {
|
||||
if (uidsForCurrent[idx] !== 0) {
|
||||
memo.searchRemove.push(['ap.preferredUsername:sorted', `${username.toLowerCase()}:${profile.uid}`]);
|
||||
memo.handleRemove.push(username.toLowerCase());
|
||||
}
|
||||
|
||||
memo.searchAdd.push(['ap.preferredUsername:sorted', 0, `${profile.username.toLowerCase()}:${profile.uid}`]);
|
||||
memo.handleAdd[profile.username.toLowerCase()] = profile.uid;
|
||||
}
|
||||
|
||||
if (profile.fullname && fullname !== profile.fullname) {
|
||||
if (fullname && uidsForCurrent[idx] !== 0) {
|
||||
memo.searchRemove.push(['ap.name:sorted', `${fullname.toLowerCase()}:${profile.uid}`]);
|
||||
}
|
||||
|
||||
memo.searchAdd.push(['ap.name:sorted', 0, `${profile.fullname.toLowerCase()}:${profile.uid}`]);
|
||||
}
|
||||
|
||||
return memo;
|
||||
}, { searchRemove: [], searchAdd: [], handleRemove: [], handleAdd: {} });
|
||||
|
||||
await Promise.all([
|
||||
db.setObjectBulk(bulkSet),
|
||||
db.sortedSetAdd('usersRemote:lastCrawled', profiles.map(() => now), profiles.map(p => p.uid)),
|
||||
db.sortedSetRemoveBulk(queries.searchRemove),
|
||||
db.sortedSetAddBulk(queries.searchAdd),
|
||||
db.deleteObjectFields('handle:uid', queries.handleRemove),
|
||||
db.setObject('handle:uid', queries.handleAdd),
|
||||
]);
|
||||
|
||||
return actors;
|
||||
};
|
||||
|
||||
Actors.getLocalFollowers = async (id) => {
|
||||
const response = {
|
||||
uids: new Set(),
|
||||
cids: new Set(),
|
||||
};
|
||||
|
||||
if (!activitypub.helpers.isUri(id)) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const members = await db.getSortedSetMembers(`followersRemote:${id}`);
|
||||
|
||||
members.forEach((id) => {
|
||||
if (utils.isNumber(id)) {
|
||||
response.uids.add(parseInt(id, 10));
|
||||
} else if (id.startsWith('cid|') && utils.isNumber(id.slice(4))) {
|
||||
response.cids.add(parseInt(id.slice(4), 10));
|
||||
}
|
||||
});
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
Actors.getLocalFollowCounts = async (actor) => {
|
||||
let followers = 0; // x local followers
|
||||
let following = 0; // following x local users
|
||||
if (!activitypub.helpers.isUri(actor)) {
|
||||
return { followers, following };
|
||||
}
|
||||
|
||||
[followers, following] = await Promise.all([
|
||||
db.sortedSetCard(`followersRemote:${actor}`),
|
||||
db.sortedSetCard(`followingRemote:${actor}`),
|
||||
]);
|
||||
|
||||
return { followers, following };
|
||||
};
|
||||
|
||||
Actors.remove = async (id) => {
|
||||
/**
|
||||
* Remove ActivityPub related metadata pertaining to a remote id
|
||||
*
|
||||
* Note: don't call this directly! It is called as part of user.deleteAccount
|
||||
*/
|
||||
const exists = await db.isSortedSetMember('usersRemote:lastCrawled', id);
|
||||
if (!exists) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let { username, fullname, url, followersUrl } = await user.getUserFields(id, ['username', 'fullname', 'url', 'followersUrl']);
|
||||
username = username.toLowerCase();
|
||||
|
||||
const bulkRemove = [
|
||||
['ap.preferredUsername:sorted', `${username}:${id}`],
|
||||
];
|
||||
if (fullname) {
|
||||
bulkRemove.push(['ap.name:sorted', `${fullname.toLowerCase()}:${id}`]);
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
db.sortedSetRemoveBulk(bulkRemove),
|
||||
db.deleteObjectField('handle:uid', username),
|
||||
db.deleteObjectField('followersUrl:uid', followersUrl),
|
||||
db.deleteObjectField('remoteUrl:uid', url),
|
||||
db.delete(`userRemote:${id}:keys`),
|
||||
]);
|
||||
|
||||
await Promise.all([
|
||||
db.delete(`userRemote:${id}`),
|
||||
db.sortedSetRemove('usersRemote:lastCrawled', id),
|
||||
]);
|
||||
};
|
||||
|
||||
Actors.prune = async () => {
|
||||
/**
|
||||
* Clear out remote user accounts that do not have content on the forum anywhere
|
||||
* Re-crawl those that have not been updated recently
|
||||
*/
|
||||
winston.info('[actors/prune] Started scheduled pruning of remote user accounts');
|
||||
|
||||
const days = parseInt(meta.config.activitypubUserPruneDays, 10);
|
||||
const timestamp = Date.now() - (1000 * 60 * 60 * 24 * days);
|
||||
const uids = await db.getSortedSetRangeByScore('usersRemote:lastCrawled', 0, -1, '-inf', timestamp);
|
||||
if (!uids.length) {
|
||||
winston.info('[actors/prune] No remote users to prune, all done.');
|
||||
return;
|
||||
}
|
||||
|
||||
winston.info(`[actors/prune] Found ${uids.length} remote users last crawled more than ${days} days ago`);
|
||||
let deletionCount = 0;
|
||||
|
||||
await batch.processArray(uids, async (uids) => {
|
||||
const exists = await db.exists(uids.map(uid => `userRemote:${uid}`));
|
||||
const postCounts = await db.sortedSetsCard(uids.map(uid => `uid:${uid}:posts`));
|
||||
await Promise.all(uids.map(async (uid, idx) => {
|
||||
if (!exists[idx]) {
|
||||
// id in zset but not asserted, handle and return early
|
||||
await db.sortedSetRemove('usersRemote:lastCrawled', uid);
|
||||
return;
|
||||
}
|
||||
|
||||
const { followers, following } = await Actors.getLocalFollowCounts(uid);
|
||||
const postCount = postCounts[idx];
|
||||
if ([postCount, followers, following].every(metric => metric < 1)) {
|
||||
try {
|
||||
await user.deleteAccount(uid);
|
||||
deletionCount += 1;
|
||||
} catch (err) {
|
||||
winston.error(err.stack);
|
||||
}
|
||||
}
|
||||
}));
|
||||
}, {
|
||||
batch: 50,
|
||||
interval: 1000,
|
||||
});
|
||||
|
||||
winston.info(`[actors/prune] ${deletionCount} remote users pruned.`);
|
||||
};
|
||||
150
src/activitypub/contexts.js
Normal file
150
src/activitypub/contexts.js
Normal file
@@ -0,0 +1,150 @@
|
||||
'use strict';
|
||||
|
||||
const winston = require('winston');
|
||||
|
||||
const db = require('../database');
|
||||
const posts = require('../posts');
|
||||
const topics = require('../topics');
|
||||
|
||||
const activitypub = module.parent.exports;
|
||||
const Contexts = module.exports;
|
||||
|
||||
const acceptableTypes = ['Collection', 'CollectionPage', 'OrderedCollection', 'OrderedCollectionPage'];
|
||||
|
||||
Contexts.get = async (uid, id) => {
|
||||
let context;
|
||||
let type;
|
||||
|
||||
// Generate digest for If-None-Match if locally cached
|
||||
const tid = await posts.getPostField(id, 'tid');
|
||||
const headers = {};
|
||||
if (tid) {
|
||||
const [mainPid, pids] = await Promise.all([
|
||||
topics.getTopicField(tid, 'mainPid'),
|
||||
db.getSortedSetMembers(`tid:${tid}:posts`),
|
||||
]);
|
||||
pids.push(mainPid);
|
||||
const digest = activitypub.helpers.generateDigest(new Set(pids));
|
||||
headers['If-None-Match'] = `"${digest}"`;
|
||||
}
|
||||
|
||||
try {
|
||||
({ context } = await activitypub.get('uid', uid, id, { headers }));
|
||||
if (!context) {
|
||||
winston.verbose(`[activitypub/context] ${id} contains no context.`);
|
||||
return false;
|
||||
}
|
||||
({ type } = await activitypub.get('uid', uid, context));
|
||||
} catch (e) {
|
||||
if (e.code === 'ap_get_304') {
|
||||
winston.verbose(`[activitypub/context] ${id} context unchanged.`);
|
||||
return { tid };
|
||||
}
|
||||
|
||||
winston.verbose(`[activitypub/context] ${id} context not resolvable.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (acceptableTypes.includes(type)) {
|
||||
return { context };
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
Contexts.getItems = async (uid, id, options) => {
|
||||
if (!options.hasOwnProperty('root')) {
|
||||
options.root = true;
|
||||
}
|
||||
|
||||
winston.verbose(`[activitypub/context] Retrieving context ${id}`);
|
||||
let { type, items, orderedItems, first, next } = await activitypub.get('uid', uid, id);
|
||||
if (!acceptableTypes.includes(type)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (type.startsWith('Ordered') && orderedItems) {
|
||||
items = orderedItems;
|
||||
}
|
||||
|
||||
if (items) {
|
||||
items = await Promise.all(items
|
||||
.map(async item => (activitypub.helpers.isUri(item) ? parseString(uid, item) : parseItem(uid, item))));
|
||||
items = items.filter(Boolean);
|
||||
winston.verbose(`[activitypub/context] Found ${items.length} items.`);
|
||||
}
|
||||
|
||||
const chain = new Set(items || []);
|
||||
if (!next && options.root && first) {
|
||||
next = first;
|
||||
}
|
||||
|
||||
if (next) {
|
||||
winston.verbose('[activitypub/context] Fetching next page...');
|
||||
Array
|
||||
.from(await Contexts.getItems(uid, next, {
|
||||
...options,
|
||||
root: false,
|
||||
}))
|
||||
.forEach((item) => {
|
||||
chain.add(item);
|
||||
});
|
||||
}
|
||||
|
||||
// Handle special case where originating object is not actually part of the context collection
|
||||
const inputId = activitypub.helpers.isUri(options.input) ? options.input : options.input.id;
|
||||
const inCollection = Array.from(chain).map(p => p.pid).includes(inputId);
|
||||
if (!inCollection) {
|
||||
chain.add(activitypub.helpers.isUri(options.input) ?
|
||||
await parseString(uid, options.input) :
|
||||
await parseItem(uid, options.input));
|
||||
}
|
||||
|
||||
return chain;
|
||||
};
|
||||
|
||||
async function parseString(uid, item) {
|
||||
const { type, id } = await activitypub.helpers.resolveLocalId(item);
|
||||
const pid = type === 'post' && id ? id : item;
|
||||
const postData = await posts.getPostData(pid);
|
||||
if (postData) {
|
||||
// Already cached
|
||||
return postData;
|
||||
}
|
||||
|
||||
// No local copy, fetch from source
|
||||
try {
|
||||
const object = await activitypub.get('uid', uid, pid);
|
||||
winston.verbose(`[activitypub/context] Retrieved ${pid}`);
|
||||
|
||||
return parseItem(uid, object);
|
||||
} catch (e) {
|
||||
// Unresolvable, either temporarily or permanent, ignore for now.
|
||||
winston.verbose(`[activitypub/context] Cannot retrieve ${pid}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function parseItem(uid, item) {
|
||||
const { type, id } = await activitypub.helpers.resolveLocalId(item.id);
|
||||
const pid = type === 'post' && id ? id : item.id;
|
||||
const postData = await posts.getPostData(pid);
|
||||
if (postData) {
|
||||
// Already cached
|
||||
return postData;
|
||||
}
|
||||
|
||||
// Handle activity wrapper
|
||||
if (item.type === 'Create') {
|
||||
item = item.object;
|
||||
if (activitypub.helpers.isUri(item)) {
|
||||
return parseString(uid, item);
|
||||
}
|
||||
} else if (!activitypub._constants.acceptedPostTypes.includes(item.type)) {
|
||||
// Not a note, silently skip.
|
||||
return null;
|
||||
}
|
||||
|
||||
winston.verbose(`[activitypub/context] Parsing ${pid}`);
|
||||
return await activitypub.mocks.post(item);
|
||||
}
|
||||
432
src/activitypub/helpers.js
Normal file
432
src/activitypub/helpers.js
Normal file
@@ -0,0 +1,432 @@
|
||||
'use strict';
|
||||
|
||||
const { generateKeyPairSync } = require('crypto');
|
||||
const nconf = require('nconf');
|
||||
const validator = require('validator');
|
||||
const cheerio = require('cheerio');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const meta = require('../meta');
|
||||
const posts = require('../posts');
|
||||
const categories = require('../categories');
|
||||
const request = require('../request');
|
||||
const db = require('../database');
|
||||
const ttl = require('../cache/ttl');
|
||||
const user = require('../user');
|
||||
const utils = require('../utils');
|
||||
const activitypub = require('.');
|
||||
|
||||
const webfingerRegex = /^(@|acct:)?[\w-]+@.+$/;
|
||||
const webfingerCache = ttl({
|
||||
max: 5000,
|
||||
ttl: 1000 * 60 * 60 * 24, // 24 hours
|
||||
});
|
||||
const sha256 = payload => crypto.createHash('sha256').update(payload).digest('hex');
|
||||
|
||||
const Helpers = module.exports;
|
||||
|
||||
Helpers.isUri = (value) => {
|
||||
if (typeof value !== 'string') {
|
||||
value = String(value);
|
||||
}
|
||||
|
||||
return validator.isURL(value, {
|
||||
require_protocol: true,
|
||||
require_host: true,
|
||||
protocols: activitypub._constants.acceptedProtocols,
|
||||
require_valid_protocol: true,
|
||||
require_tld: false, // temporary — for localhost
|
||||
});
|
||||
};
|
||||
|
||||
Helpers.isWebfinger = (value) => {
|
||||
// N.B. returns normalized handle, so truthy check!
|
||||
if (webfingerRegex.test(value) && !Helpers.isUri(value)) {
|
||||
if (value.startsWith('@')) {
|
||||
return value.slice(1);
|
||||
} else if (value.startsWith('acct:')) {
|
||||
return value.slice(5);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
Helpers.query = async (id) => {
|
||||
const isUri = Helpers.isUri(id);
|
||||
// username@host ids use acct: URI schema
|
||||
const uri = isUri ? new URL(id) : new URL(`acct:${id}`);
|
||||
// JS doesn't parse anything other than protocol and pathname from acct: URIs, so we need to just split id manually
|
||||
let [username, hostname] = isUri ? [uri.pathname || uri.href, uri.host] : id.split('@');
|
||||
if (!username || !hostname) {
|
||||
return false;
|
||||
}
|
||||
username = username.trim();
|
||||
hostname = hostname.trim();
|
||||
|
||||
const cached = webfingerCache.get(id);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const query = new URLSearchParams({ resource: uri });
|
||||
|
||||
// Make a webfinger query to retrieve routing information
|
||||
let response;
|
||||
let body;
|
||||
try {
|
||||
({ response, body } = await request.get(`https://${hostname}/.well-known/webfinger?${query}`));
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (response.statusCode !== 200 || !body.hasOwnProperty('links')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse links to find actor endpoint
|
||||
let actorUri = body.links.filter(link => activitypub._constants.acceptableTypes.includes(link.type) && link.rel === 'self');
|
||||
if (actorUri.length) {
|
||||
actorUri = actorUri.pop();
|
||||
({ href: actorUri } = actorUri);
|
||||
}
|
||||
|
||||
const { subject, publicKey } = body;
|
||||
const payload = { subject, username, hostname, actorUri, publicKey };
|
||||
|
||||
const claimedId = new URL(subject).pathname;
|
||||
webfingerCache.set(claimedId, payload);
|
||||
if (claimedId !== id) {
|
||||
webfingerCache.set(id, payload);
|
||||
}
|
||||
|
||||
return payload;
|
||||
};
|
||||
|
||||
Helpers.generateKeys = async (type, id) => {
|
||||
// winston.verbose(`[activitypub] Generating RSA key-pair for ${type} ${id}`);
|
||||
const {
|
||||
publicKey,
|
||||
privateKey,
|
||||
} = generateKeyPairSync('rsa', {
|
||||
modulusLength: 2048,
|
||||
publicKeyEncoding: {
|
||||
type: 'spki',
|
||||
format: 'pem',
|
||||
},
|
||||
privateKeyEncoding: {
|
||||
type: 'pkcs8',
|
||||
format: 'pem',
|
||||
},
|
||||
});
|
||||
|
||||
await db.setObject(`${type}:${id}:keys`, { publicKey, privateKey });
|
||||
return { publicKey, privateKey };
|
||||
};
|
||||
|
||||
Helpers.resolveLocalId = async (input) => {
|
||||
if (Helpers.isUri(input)) {
|
||||
const { host, pathname, hash } = new URL(input);
|
||||
|
||||
if (host === nconf.get('url_parsed').host) {
|
||||
const [prefix, value] = pathname.replace(nconf.get('relative_path'), '').split('/').filter(Boolean);
|
||||
|
||||
let activityData = {};
|
||||
if (hash.startsWith('#activity')) {
|
||||
const [, activity, data, timestamp] = hash.split('/', 4);
|
||||
activityData = { activity, data, timestamp };
|
||||
}
|
||||
|
||||
switch (prefix) {
|
||||
case 'uid':
|
||||
return { type: 'user', id: value, ...activityData };
|
||||
|
||||
case 'post':
|
||||
return { type: 'post', id: value, ...activityData };
|
||||
|
||||
case 'cid':
|
||||
case 'category':
|
||||
return { type: 'category', id: value, ...activityData };
|
||||
|
||||
case 'user': {
|
||||
const uid = await user.getUidByUserslug(value);
|
||||
return { type: 'user', id: uid, ...activityData };
|
||||
}
|
||||
}
|
||||
|
||||
return { type: null, id: null, ...activityData };
|
||||
}
|
||||
|
||||
return { type: null, id: null };
|
||||
} else if (String(input).indexOf('@') !== -1) { // Webfinger
|
||||
input = decodeURIComponent(input);
|
||||
const [slug] = input.replace(/^(acct:|@)/, '').split('@');
|
||||
const uid = await user.getUidByUserslug(slug);
|
||||
return { type: 'user', id: uid };
|
||||
}
|
||||
|
||||
return { type: null, id: null };
|
||||
};
|
||||
|
||||
Helpers.resolveActor = (type, id) => {
|
||||
switch (type) {
|
||||
case 'user':
|
||||
case 'uid': {
|
||||
return `${nconf.get('url')}${id > 0 ? `/uid/${id}` : '/actor'}`;
|
||||
}
|
||||
|
||||
case 'category':
|
||||
case 'cid': {
|
||||
return `${nconf.get('url')}/category/${id}`;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error('[[error:activitypub.invalid-id]]');
|
||||
}
|
||||
};
|
||||
|
||||
Helpers.resolveActivity = async (activity, data, id, resolved) => {
|
||||
switch (activity.toLowerCase()) {
|
||||
case 'follow': {
|
||||
const actor = await Helpers.resolveActor(resolved.type, resolved.id);
|
||||
const { actorUri: targetUri } = await Helpers.query(data);
|
||||
return {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
actor,
|
||||
id,
|
||||
type: 'Follow',
|
||||
object: targetUri,
|
||||
};
|
||||
}
|
||||
case 'announce':
|
||||
case 'create': {
|
||||
const object = await Helpers.resolveObjects(resolved.id);
|
||||
// local create activities are assumed to come from the user who created the underlying object
|
||||
const actor = object.attributedTo || object.actor;
|
||||
return {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
actor,
|
||||
id,
|
||||
type: 'Create',
|
||||
object,
|
||||
};
|
||||
}
|
||||
default: {
|
||||
throw new Error('[[error:activitypub.not-implemented]]');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Helpers.mapToLocalType = (type) => {
|
||||
if (type === 'Person') {
|
||||
return 'user';
|
||||
}
|
||||
if (type === 'Group') {
|
||||
return 'category';
|
||||
}
|
||||
if (type === 'Hashtag') {
|
||||
return 'tag';
|
||||
}
|
||||
if (activitypub._constants.acceptedPostTypes.includes(type)) {
|
||||
return 'post';
|
||||
}
|
||||
};
|
||||
|
||||
Helpers.resolveObjects = async (ids) => {
|
||||
if (!Array.isArray(ids)) {
|
||||
ids = [ids];
|
||||
}
|
||||
const objects = await Promise.all(ids.map(async (id) => {
|
||||
// try to get a local ID first
|
||||
const { type, id: resolvedId, activity, data: activityData } = await Helpers.resolveLocalId(id);
|
||||
// activity data is only resolved for local IDs - so this will be false for remote posts
|
||||
if (activity) {
|
||||
return Helpers.resolveActivity(activity, activityData, id, { type, id: resolvedId });
|
||||
}
|
||||
switch (type) {
|
||||
case 'user': {
|
||||
if (!await user.exists(resolvedId)) {
|
||||
throw new Error('[[error:activitypub.invalid-id]]');
|
||||
}
|
||||
return activitypub.mocks.actors.user(resolvedId);
|
||||
}
|
||||
case 'post': {
|
||||
const post = (await posts.getPostSummaryByPids(
|
||||
[resolvedId],
|
||||
activitypub._constants.uid,
|
||||
{ stripTags: false }
|
||||
)).pop();
|
||||
if (!post) {
|
||||
throw new Error('[[error:activitypub.invalid-id]]');
|
||||
}
|
||||
return activitypub.mocks.note(post);
|
||||
}
|
||||
case 'category': {
|
||||
if (!await categories.exists(resolvedId)) {
|
||||
throw new Error('[[error:activitypub.invalid-id]]');
|
||||
}
|
||||
return activitypub.mocks.actors.category(resolvedId);
|
||||
}
|
||||
// if the type is not recognized, assume it's not a local ID and fetch the object from its origin
|
||||
default: {
|
||||
return activitypub.get('uid', 0, id);
|
||||
}
|
||||
}
|
||||
}));
|
||||
return objects.length === 1 ? objects[0] : objects;
|
||||
};
|
||||
|
||||
Helpers.generateTitle = (html) => {
|
||||
// Given an html string, generates a more appropriate title if possible
|
||||
const $ = cheerio.load(html);
|
||||
let title;
|
||||
|
||||
// Try the first paragraph element
|
||||
title = $('h1, h2, h3, h4, h5, h6, title, p, span').first().text();
|
||||
|
||||
// Fall back to newline splitting (i.e. if no paragraph elements)
|
||||
title = title || html.split('\n').filter(Boolean).shift();
|
||||
|
||||
// Strip html
|
||||
title = utils.stripHTMLTags(title);
|
||||
|
||||
// Split sentences and use only first one
|
||||
const sentences = title
|
||||
.split(/(\.|\?|!)\s/)
|
||||
.reduce((memo, cur, idx, sentences) => {
|
||||
if (idx % 2) {
|
||||
memo.push(`${sentences[idx - 1]}${cur}`);
|
||||
}
|
||||
|
||||
return memo;
|
||||
}, []);
|
||||
|
||||
if (sentences.length > 1) {
|
||||
title = sentences.shift();
|
||||
}
|
||||
|
||||
// Truncate down if too long
|
||||
if (title.length > meta.config.maximumTitleLength) {
|
||||
title = `${title.slice(0, meta.config.maximumTitleLength - 3)}...`;
|
||||
}
|
||||
|
||||
return title;
|
||||
};
|
||||
|
||||
Helpers.remoteAnchorToLocalProfile = async (content) => {
|
||||
const anchorRegex = /<a.*?href=['"](.+?)['"].*?>(.*?)<\/a>/ig;
|
||||
const anchors = content.matchAll(anchorRegex);
|
||||
const urls = new Set();
|
||||
const matches = [];
|
||||
for (const anchor of anchors) {
|
||||
const [match, url] = anchor;
|
||||
matches.push([match, url]);
|
||||
urls.add(url);
|
||||
}
|
||||
|
||||
if (!urls.size) {
|
||||
return content;
|
||||
}
|
||||
|
||||
// Filter out urls that don't backreference to a remote id
|
||||
const urlsArray = Array.from(urls);
|
||||
const [backrefs, urlAsIdExists] = await Promise.all([
|
||||
db.getObjectFields('remoteUrl:uid', urlsArray),
|
||||
db.isSortedSetMembers('usersRemote:lastCrawled', urlsArray),
|
||||
]);
|
||||
|
||||
const urlMap = new Map();
|
||||
urlsArray.forEach((url, index) => {
|
||||
if (backrefs[url] || urlAsIdExists[index]) {
|
||||
urlMap.set(url, backrefs[url] || url);
|
||||
}
|
||||
});
|
||||
let slugs = await user.getUsersFields(Array.from(urlMap.values()), ['userslug']);
|
||||
slugs = slugs.map(({ userslug }) => userslug);
|
||||
Array.from(urlMap.keys()).forEach((url, idx) => {
|
||||
urlMap.set(url, `/user/${encodeURIComponent(slugs[idx])}`);
|
||||
});
|
||||
|
||||
// Modify existing anchors to local profile
|
||||
matches.forEach(([match, href]) => {
|
||||
const replacementHref = urlMap.get(href);
|
||||
if (replacementHref) {
|
||||
const replacement = match.replace(href, replacementHref);
|
||||
content = content.split(match).join(replacement);
|
||||
}
|
||||
});
|
||||
|
||||
return content;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line max-len
|
||||
Helpers.makeSet = (object, properties) => new Set(properties.reduce((memo, property) => memo.concat(Array.isArray(object[property]) ? object[property] : [object[property]]), []));
|
||||
|
||||
Helpers.generateCollection = async ({ set, method, page, perPage, url }) => {
|
||||
if (!method) {
|
||||
method = db.getSortedSetRange;
|
||||
}
|
||||
const count = await db.sortedSetCard(set);
|
||||
const pageCount = Math.max(1, Math.ceil(count / perPage));
|
||||
let items = [];
|
||||
let paginate = true;
|
||||
|
||||
if (!page && pageCount === 1) {
|
||||
page = 1;
|
||||
paginate = false;
|
||||
}
|
||||
|
||||
if (page) {
|
||||
const invalidPagination = page < 1 || page > pageCount;
|
||||
if (invalidPagination) {
|
||||
throw new Error('[[error:invalid-data]]');
|
||||
}
|
||||
|
||||
const start = Math.max(0, ((page - 1) * perPage) - 1);
|
||||
const stop = Math.max(0, start + perPage - 1);
|
||||
items = await method(set, start, stop);
|
||||
}
|
||||
|
||||
const object = {
|
||||
type: paginate && items ? 'OrderedCollectionPage' : 'OrderedCollection',
|
||||
totalItems: count,
|
||||
};
|
||||
|
||||
if (items.length) {
|
||||
object.orderedItems = items;
|
||||
|
||||
if (paginate) {
|
||||
object.partOf = url;
|
||||
object.next = page < pageCount ? `${url}?page=${page + 1}` : null;
|
||||
object.prev = page > 1 ? `${url}?page=${page - 1}` : null;
|
||||
}
|
||||
} else {
|
||||
object.orderedItems = [];
|
||||
}
|
||||
|
||||
if (paginate) {
|
||||
object.first = `${url}?page=1`;
|
||||
object.last = `${url}?page=${pageCount}`;
|
||||
}
|
||||
|
||||
return object;
|
||||
};
|
||||
|
||||
Helpers.generateDigest = (set) => {
|
||||
if (!(set instanceof Set)) {
|
||||
throw new Error('[[error:invalid-data]]');
|
||||
}
|
||||
|
||||
return Array
|
||||
.from(set)
|
||||
.map(item => sha256(item))
|
||||
.reduce((memo, cur) => {
|
||||
const a = Buffer.from(memo, 'hex');
|
||||
const b = Buffer.from(cur, 'hex');
|
||||
// eslint-disable-next-line no-bitwise
|
||||
const result = a.map((x, i) => x ^ b[i]);
|
||||
return result.toString('hex');
|
||||
});
|
||||
};
|
||||
530
src/activitypub/inbox.js
Normal file
530
src/activitypub/inbox.js
Normal file
@@ -0,0 +1,530 @@
|
||||
'use strict';
|
||||
|
||||
const winston = require('winston');
|
||||
const nconf = require('nconf');
|
||||
|
||||
const db = require('../database');
|
||||
const privileges = require('../privileges');
|
||||
const user = require('../user');
|
||||
const posts = require('../posts');
|
||||
const topics = require('../topics');
|
||||
const categories = require('../categories');
|
||||
const notifications = require('../notifications');
|
||||
const flags = require('../flags');
|
||||
const api = require('../api');
|
||||
const activitypub = require('.');
|
||||
|
||||
const socketHelpers = require('../socket.io/helpers');
|
||||
const helpers = require('./helpers');
|
||||
|
||||
const inbox = module.exports;
|
||||
|
||||
function reject(type, object, target, senderType = 'uid', id = 0) {
|
||||
activitypub.send(senderType, id, target, {
|
||||
id: `${helpers.resolveActor(senderType, id)}#/activity/reject/${encodeURIComponent(object.id)}`,
|
||||
type: 'Reject',
|
||||
object: {
|
||||
type,
|
||||
target,
|
||||
object,
|
||||
},
|
||||
}).catch(err => winston.error(err.stack));
|
||||
}
|
||||
|
||||
// FEP 1b12
|
||||
async function announce(id, activity) {
|
||||
let localId;
|
||||
if (id.startsWith(nconf.get('url'))) {
|
||||
({ id: localId } = await activitypub.helpers.resolveLocalId(id));
|
||||
}
|
||||
const cid = await posts.getCidByPid(localId || id);
|
||||
|
||||
const followers = await activitypub.notes.getCategoryFollowers(cid);
|
||||
if (!followers.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { actor } = activity;
|
||||
followers.unshift(actor);
|
||||
|
||||
winston.info(`[activitypub/inbox.announce(1b12)] Announcing ${activity.type} to followers of cid ${cid}`);
|
||||
await Promise.all([activity, activity.object].map(async (object) => {
|
||||
await activitypub.send('cid', cid, followers, {
|
||||
id: `${nconf.get('url')}/post/${encodeURIComponent(id)}#activity/announce/${Date.now()}`,
|
||||
type: 'Announce',
|
||||
to: [`${nconf.get('url')}/category/${cid}/followers`],
|
||||
cc: [actor, activitypub._constants.publicAddress],
|
||||
object,
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
inbox.create = async (req) => {
|
||||
const { object } = req.body;
|
||||
|
||||
// Temporary, reject non-public notes.
|
||||
if (![...object.to, ...object.cc].includes(activitypub._constants.publicAddress)) {
|
||||
throw new Error('[[error:activitypub.not-implemented]]');
|
||||
}
|
||||
|
||||
const asserted = await activitypub.notes.assert(0, object);
|
||||
if (asserted) {
|
||||
announce(object.id, req.body);
|
||||
}
|
||||
};
|
||||
|
||||
inbox.update = async (req) => {
|
||||
const { actor, object } = req.body;
|
||||
|
||||
// Origin checking
|
||||
const actorHostname = new URL(actor).hostname;
|
||||
const objectHostname = new URL(object.id).hostname;
|
||||
if (actorHostname !== objectHostname) {
|
||||
throw new Error('[[error:activitypub.origin-mismatch]]');
|
||||
}
|
||||
|
||||
switch (object.type) {
|
||||
case 'Note': {
|
||||
const postData = await activitypub.mocks.post(object);
|
||||
const exists = await posts.exists(object.id);
|
||||
try {
|
||||
if (exists) {
|
||||
await posts.edit(postData);
|
||||
const isDeleted = await posts.getPostField(object.id, 'deleted');
|
||||
if (isDeleted) {
|
||||
await api.posts.restore({ uid: actor }, { pid: object.id });
|
||||
}
|
||||
} else {
|
||||
const asserted = await activitypub.notes.assert(0, object.id);
|
||||
if (asserted) {
|
||||
announce(object.id, req.body);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
reject('Update', object, actor);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'Application': // falls through
|
||||
case 'Group': // falls through
|
||||
case 'Organization': // falls through
|
||||
case 'Service': // falls through
|
||||
case 'Person': {
|
||||
await activitypub.actors.assert(object.id, { update: true });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'Tombstone': {
|
||||
const [isNote/* , isActor */] = await Promise.all([
|
||||
posts.exists(object.id),
|
||||
// db.isSortedSetMember('usersRemote:lastCrawled', object.id),
|
||||
]);
|
||||
|
||||
switch (true) {
|
||||
case isNote: {
|
||||
await api.posts.delete({ uid: actor }, { pid: object.id });
|
||||
break;
|
||||
}
|
||||
|
||||
// case isActor: {
|
||||
// console.log('actor');
|
||||
// break;
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
inbox.delete = async (req) => {
|
||||
const { actor, object } = req.body;
|
||||
|
||||
// Deletes don't have their objects resolved automatically
|
||||
let method = 'purge';
|
||||
try {
|
||||
const { type } = await activitypub.get('uid', 0, object);
|
||||
if (type === 'Tombstone') {
|
||||
method = 'delete';
|
||||
}
|
||||
} catch (e) {
|
||||
// probably 410/404
|
||||
}
|
||||
|
||||
// Origin checking
|
||||
const actorHostname = new URL(actor).hostname;
|
||||
const objectHostname = new URL(object).hostname;
|
||||
if (actorHostname !== objectHostname) {
|
||||
throw new Error('[[error:activitypub.origin-mismatch]]');
|
||||
}
|
||||
|
||||
const [isNote/* , isActor */] = await Promise.all([
|
||||
posts.exists(object),
|
||||
// db.isSortedSetMember('usersRemote:lastCrawled', object.id),
|
||||
]);
|
||||
|
||||
switch (true) {
|
||||
case isNote: {
|
||||
const uid = await posts.getPostField(object, 'uid');
|
||||
await announce(object, req.body);
|
||||
await api.posts[method]({ uid }, { pid: object });
|
||||
break;
|
||||
}
|
||||
|
||||
// case isActor: {
|
||||
// console.log('actor');
|
||||
// break;
|
||||
// }
|
||||
|
||||
default: {
|
||||
// winston.verbose(`[activitypub/inbox.delete] Object (${object}) does not exist locally. Doing nothing.`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
inbox.like = async (req) => {
|
||||
const { actor, object } = req.body;
|
||||
const { type, id } = await activitypub.helpers.resolveLocalId(object.id);
|
||||
|
||||
if (type !== 'post' || !(await posts.exists(id))) {
|
||||
return reject('Like', object, actor);
|
||||
}
|
||||
|
||||
const allowed = await privileges.posts.can('posts:upvote', id, activitypub._constants.uid);
|
||||
if (!allowed) {
|
||||
winston.verbose(`[activitypub/inbox.like] ${id} not allowed to be upvoted.`);
|
||||
return reject('Like', object, actor);
|
||||
}
|
||||
|
||||
winston.verbose(`[activitypub/inbox/like] id ${id} via ${actor}`);
|
||||
|
||||
const result = await posts.upvote(id, actor);
|
||||
announce(object.id, req.body);
|
||||
socketHelpers.upvote(result, 'notifications:upvoted-your-post-in');
|
||||
};
|
||||
|
||||
inbox.announce = async (req) => {
|
||||
const { actor, object, published, to, cc } = req.body;
|
||||
let timestamp = new Date(published);
|
||||
timestamp = timestamp.toString() !== 'Invalid Date' ? timestamp.getTime() : Date.now();
|
||||
|
||||
const assertion = await activitypub.actors.assert(actor);
|
||||
if (!assertion) {
|
||||
throw new Error('[[error:activitypub.invalid-id]]');
|
||||
}
|
||||
|
||||
let tid;
|
||||
let pid;
|
||||
|
||||
const { cids } = await activitypub.actors.getLocalFollowers(actor);
|
||||
let cid = null;
|
||||
if (cids.size > 0) {
|
||||
cid = Array.from(cids)[0];
|
||||
}
|
||||
|
||||
if (String(object.id).startsWith(nconf.get('url'))) { // Local object
|
||||
const { type, id } = await activitypub.helpers.resolveLocalId(object.id);
|
||||
if (type !== 'post' || !(await posts.exists(id))) {
|
||||
throw new Error('[[error:activitypub.invalid-id]]');
|
||||
}
|
||||
|
||||
pid = id;
|
||||
tid = await posts.getPostField(id, 'tid');
|
||||
|
||||
socketHelpers.sendNotificationToPostOwner(pid, actor, 'announce', 'notifications:activitypub.announce');
|
||||
} else { // Remote object
|
||||
// Follower check
|
||||
if (!cid) {
|
||||
const { followers } = await activitypub.actors.getLocalFollowCounts(actor);
|
||||
if (!followers) {
|
||||
winston.verbose(`[activitypub/inbox.announce] Rejecting ${object.id} via ${actor} due to no followers`);
|
||||
reject('Announce', object, actor);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle case where Announce(Create(Note-ish)) is received
|
||||
if (object.type === 'Create' && activitypub._constants.acceptedPostTypes.includes(object.object.type)) {
|
||||
pid = object.object.id;
|
||||
} else {
|
||||
pid = object.id;
|
||||
}
|
||||
|
||||
pid = await activitypub.resolveId(0, pid); // in case wrong id is passed-in; unlikely, but still.
|
||||
if (!pid) {
|
||||
return;
|
||||
}
|
||||
|
||||
({ tid } = await activitypub.notes.assert(0, pid, { cid, skipChecks: true })); // checks skipped; done above.
|
||||
if (!tid) {
|
||||
return;
|
||||
}
|
||||
|
||||
await topics.updateLastPostTime(tid, timestamp);
|
||||
await activitypub.notes.updateLocalRecipients(pid, { to, cc });
|
||||
await activitypub.notes.syncUserInboxes(tid);
|
||||
}
|
||||
|
||||
winston.verbose(`[activitypub/inbox/announce] Parsing id ${pid}`);
|
||||
|
||||
if (!cid) { // Topic events from actors followed by users only
|
||||
await activitypub.notes.announce.add(pid, actor, timestamp);
|
||||
}
|
||||
};
|
||||
|
||||
inbox.follow = async (req) => {
|
||||
const { actor, object, id: followId } = req.body;
|
||||
// Sanity checks
|
||||
const { type, id } = await helpers.resolveLocalId(object.id);
|
||||
if (!['category', 'user'].includes(type)) {
|
||||
throw new Error('[[error:activitypub.invalid-id]]');
|
||||
}
|
||||
|
||||
const assertion = await activitypub.actors.assert(actor);
|
||||
if (!assertion) {
|
||||
throw new Error('[[error:activitypub.invalid-id]]');
|
||||
}
|
||||
const handle = await user.getUserField(actor, 'username');
|
||||
|
||||
if (type === 'user') {
|
||||
const [exists, allowed] = await Promise.all([
|
||||
user.exists(id),
|
||||
privileges.global.can('view:users', activitypub._constants.uid),
|
||||
]);
|
||||
if (!exists || !allowed) {
|
||||
throw new Error('[[error:invalid-uid]]');
|
||||
}
|
||||
|
||||
const isFollowed = await inbox.isFollowed(actor, id);
|
||||
if (isFollowed) {
|
||||
// No additional parsing required
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
await db.sortedSetAdd(`followersRemote:${id}`, now, actor);
|
||||
await db.sortedSetAdd(`followingRemote:${actor}`, now, id); // for following backreference (actor pruning)
|
||||
|
||||
const followerRemoteCount = await db.sortedSetCard(`followersRemote:${id}`);
|
||||
await user.setUserField(id, 'followerRemoteCount', followerRemoteCount);
|
||||
|
||||
await user.onFollow(actor, id);
|
||||
activitypub.send('uid', id, actor, {
|
||||
id: `${nconf.get('url')}/${type}/${id}#activity/accept:follow/${handle}/${Date.now()}`,
|
||||
type: 'Accept',
|
||||
object: {
|
||||
id: followId,
|
||||
type: 'Follow',
|
||||
actor,
|
||||
object: object.id,
|
||||
},
|
||||
}).catch(err => winston.error(err.stack));
|
||||
} else if (type === 'category') {
|
||||
const [exists, allowed] = await Promise.all([
|
||||
categories.exists(id),
|
||||
privileges.categories.can('read', id, activitypub._constants.uid),
|
||||
]);
|
||||
if (!exists) {
|
||||
throw new Error('[[error:invalid-cid]]');
|
||||
}
|
||||
if (!allowed) {
|
||||
return reject('Follow', object, actor);
|
||||
}
|
||||
|
||||
const watchState = await categories.getWatchState([id], actor);
|
||||
if (watchState[0] !== categories.watchStates.tracking) {
|
||||
await user.setCategoryWatchState(actor, id, categories.watchStates.tracking);
|
||||
}
|
||||
|
||||
activitypub.send('cid', id, actor, {
|
||||
id: `${nconf.get('url')}/${type}/${id}#activity/accept:follow/${handle}/${Date.now()}`,
|
||||
type: 'Accept',
|
||||
object: {
|
||||
id: followId,
|
||||
type: 'Follow',
|
||||
actor,
|
||||
object: object.id,
|
||||
},
|
||||
}).catch(err => winston.error(err.stack));
|
||||
}
|
||||
};
|
||||
|
||||
inbox.isFollowed = async (actorId, uid) => {
|
||||
if (actorId.indexOf('@') === -1 || parseInt(uid, 10) <= 0) {
|
||||
return false;
|
||||
}
|
||||
return await db.isSortedSetMember(`followersRemote:${uid}`, actorId);
|
||||
};
|
||||
|
||||
inbox.accept = async (req) => {
|
||||
const { actor, object } = req.body;
|
||||
const { type } = object;
|
||||
|
||||
const { type: localType, id } = await helpers.resolveLocalId(object.actor);
|
||||
if (!['user', 'category'].includes(localType)) {
|
||||
throw new Error('[[error:invalid-data]]');
|
||||
}
|
||||
|
||||
const assertion = await activitypub.actors.assert(actor);
|
||||
if (!assertion) {
|
||||
throw new Error('[[error:activitypub.invalid-id]]');
|
||||
}
|
||||
|
||||
if (type === 'Follow') {
|
||||
if (localType === 'user') {
|
||||
if (!await db.isSortedSetMember(`followRequests:uid.${id}`, actor)) {
|
||||
if (await db.isSortedSetMember(`followingRemote:${id}`, actor)) return; // already following
|
||||
return reject('Accept', req.body, actor); // not following, not requested, so reject to hopefully stop retries
|
||||
}
|
||||
const timestamp = await db.sortedSetScore(`followRequests:uid.${id}`, actor);
|
||||
await Promise.all([
|
||||
db.sortedSetRemove(`followRequests:uid.${id}`, actor),
|
||||
db.sortedSetAdd(`followingRemote:${id}`, timestamp, actor),
|
||||
db.sortedSetAdd(`followersRemote:${actor}`, timestamp, id), // for followers backreference and notes assertion checking
|
||||
]);
|
||||
const followingRemoteCount = await db.sortedSetCard(`followingRemote:${id}`);
|
||||
await user.setUserField(id, 'followingRemoteCount', followingRemoteCount);
|
||||
} else if (localType === 'category') {
|
||||
if (!await db.isSortedSetMember(`followRequests:cid.${id}`, actor)) {
|
||||
if (await db.isSortedSetMember(`cid:${id}:following`, actor)) return; // already following
|
||||
return reject('Accept', req.body, actor); // not following, not requested, so reject to hopefully stop retries
|
||||
}
|
||||
const timestamp = await db.sortedSetScore(`followRequests:cid.${id}`, actor);
|
||||
await Promise.all([
|
||||
db.sortedSetRemove(`followRequests:cid.${id}`, actor),
|
||||
db.sortedSetAdd(`cid:${id}:following`, timestamp, actor),
|
||||
db.sortedSetAdd(`followersRemote:${actor}`, timestamp, `cid|${id}`), // for notes assertion checking
|
||||
]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
inbox.undo = async (req) => {
|
||||
// todo: "actor" in this case should be the one in object, no?
|
||||
const { actor, object } = req.body;
|
||||
const { type } = object;
|
||||
|
||||
if (actor !== object.actor) {
|
||||
throw new Error('[[error:activitypub.actor-mismatch]]');
|
||||
}
|
||||
|
||||
const assertion = await activitypub.actors.assert(actor);
|
||||
if (!assertion) {
|
||||
throw new Error('[[error:activitypub.invalid-id]]');
|
||||
}
|
||||
|
||||
let { type: localType, id } = await helpers.resolveLocalId(object.object);
|
||||
|
||||
winston.verbose(`[activitypub/inbox/undo] ${type} ${localType && id ? `${localType} ${id}` : object.object} via ${actor}`);
|
||||
|
||||
switch (type) {
|
||||
case 'Follow': {
|
||||
switch (localType) {
|
||||
case 'user': {
|
||||
const exists = await user.exists(id);
|
||||
if (!exists) {
|
||||
throw new Error('[[error:invalid-uid]]');
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
db.sortedSetRemove(`followersRemote:${id}`, actor),
|
||||
db.sortedSetRemove(`followingRemote:${actor}`, id),
|
||||
]);
|
||||
const followerRemoteCount = await db.sortedSetCard(`followerRemote:${id}`);
|
||||
await user.setUserField(id, 'followerRemoteCount', followerRemoteCount);
|
||||
notifications.rescind(`follow:${id}:uid:${actor}`);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'category': {
|
||||
const exists = await categories.exists(id);
|
||||
if (!exists) {
|
||||
throw new Error('[[error:invalid-cid]]');
|
||||
}
|
||||
|
||||
await user.setCategoryWatchState(actor, id, categories.watchStates.notwatching);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case 'Like': {
|
||||
const exists = await posts.exists(id);
|
||||
if (localType !== 'post' || !exists) {
|
||||
throw new Error('[[error:invalid-pid]]');
|
||||
}
|
||||
|
||||
const allowed = await privileges.posts.can('posts:upvote', id, activitypub._constants.uid);
|
||||
if (!allowed) {
|
||||
winston.verbose(`[activitypub/inbox.like] ${id} not allowed to be upvoted.`);
|
||||
reject('Like', object, actor);
|
||||
break;
|
||||
}
|
||||
|
||||
await posts.unvote(id, actor);
|
||||
announce(object.object, req.body);
|
||||
notifications.rescind(`upvote:post:${id}:uid:${actor}`);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'Announce': {
|
||||
id = id || object.object; // remote announces
|
||||
const exists = await posts.exists(id);
|
||||
if (!exists) {
|
||||
// winston.verbose(
|
||||
// `[activitypub/inbox/undo] Attempted to undo announce of ${id} but couldn't find it, so doing nothing.
|
||||
// `);
|
||||
break;
|
||||
}
|
||||
|
||||
await activitypub.notes.announce.remove(id, actor);
|
||||
notifications.rescind(`announce:post:${id}:uid:${actor}`);
|
||||
break;
|
||||
}
|
||||
case 'Flag': {
|
||||
if (!Array.isArray(object.object)) {
|
||||
object.object = [object.object];
|
||||
}
|
||||
await Promise.all(object.object.map(async (subject) => {
|
||||
const { type, id } = await activitypub.helpers.resolveLocalId(subject.id);
|
||||
try {
|
||||
await flags.rescindReport(type, id, actor);
|
||||
} catch (e) {
|
||||
reject('Undo', { type: 'Flag', object: [subject] }, actor);
|
||||
}
|
||||
}));
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
inbox.flag = async (req) => {
|
||||
const { actor, object, content } = req.body;
|
||||
const objects = Array.isArray(object) ? object : [object];
|
||||
|
||||
// Check if the actor is valid
|
||||
if (!await activitypub.actors.assert(actor)) {
|
||||
return reject('Flag', objects, actor);
|
||||
}
|
||||
|
||||
await Promise.all(objects.map(async (subject, index) => {
|
||||
const { type, id } = await activitypub.helpers.resolveObjects(subject.id);
|
||||
try {
|
||||
await flags.create(activitypub.helpers.mapToLocalType(type), id, actor, content);
|
||||
} catch (e) {
|
||||
reject('Flag', objects[index], actor);
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
inbox.reject = async (req) => {
|
||||
const { actor, object } = req.body;
|
||||
const { type, id } = object;
|
||||
const { hostname } = new URL(actor);
|
||||
const queueId = `${type}:${id}:${hostname}`;
|
||||
|
||||
// stop retrying rejected requests
|
||||
clearTimeout(activitypub.retryQueue.get(queueId));
|
||||
activitypub.retryQueue.delete(queueId);
|
||||
};
|
||||
381
src/activitypub/index.js
Normal file
381
src/activitypub/index.js
Normal file
@@ -0,0 +1,381 @@
|
||||
'use strict';
|
||||
|
||||
const nconf = require('nconf');
|
||||
const winston = require('winston');
|
||||
const { createHash, createSign, createVerify, getHashes } = require('crypto');
|
||||
const { CronJob } = require('cron');
|
||||
|
||||
const request = require('../request');
|
||||
const db = require('../database');
|
||||
const meta = require('../meta');
|
||||
const user = require('../user');
|
||||
const utils = require('../utils');
|
||||
const ttl = require('../cache/ttl');
|
||||
const lru = require('../cache/lru');
|
||||
const batch = require('../batch');
|
||||
const pubsub = require('../pubsub');
|
||||
const analytics = require('../analytics');
|
||||
|
||||
const requestCache = ttl({
|
||||
max: 5000,
|
||||
ttl: 1000 * 60 * 5, // 5 minutes
|
||||
});
|
||||
|
||||
const ActivityPub = module.exports;
|
||||
|
||||
ActivityPub._constants = Object.freeze({
|
||||
uid: -2,
|
||||
publicAddress: 'https://www.w3.org/ns/activitystreams#Public',
|
||||
acceptableTypes: [
|
||||
'application/activity+json',
|
||||
'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
|
||||
],
|
||||
acceptedPostTypes: [
|
||||
'Note', 'Page', 'Article', 'Question',
|
||||
],
|
||||
acceptableActorTypes: new Set(['Application', 'Group', 'Organization', 'Person', 'Service']),
|
||||
requiredActorProps: ['inbox', 'outbox'],
|
||||
acceptedProtocols: ['https', ...(process.env.CI === 'true' ? ['http'] : [])],
|
||||
});
|
||||
ActivityPub._cache = requestCache;
|
||||
|
||||
ActivityPub.helpers = require('./helpers');
|
||||
ActivityPub.inbox = require('./inbox');
|
||||
ActivityPub.mocks = require('./mocks');
|
||||
ActivityPub.notes = require('./notes');
|
||||
ActivityPub.contexts = require('./contexts');
|
||||
ActivityPub.actors = require('./actors');
|
||||
ActivityPub.instances = require('./instances');
|
||||
|
||||
ActivityPub.startJobs = () => {
|
||||
// winston.verbose('[activitypub/jobs] Registering jobs.');
|
||||
new CronJob('0 0 * * *', async () => {
|
||||
try {
|
||||
await ActivityPub.notes.prune();
|
||||
} catch (err) {
|
||||
winston.error(err.stack);
|
||||
}
|
||||
}, null, true, null, null, false); // change last argument to true for debugging
|
||||
|
||||
new CronJob('0 1 * * *', async () => {
|
||||
try {
|
||||
await ActivityPub.actors.prune();
|
||||
} catch (err) {
|
||||
winston.error(err.stack);
|
||||
}
|
||||
}, null, true, null, null, false); // change last argument to true for debugging
|
||||
};
|
||||
|
||||
ActivityPub.resolveId = async (uid, id) => {
|
||||
try {
|
||||
const query = new URL(id);
|
||||
({ id } = await ActivityPub.get('uid', uid, id));
|
||||
const response = new URL(id);
|
||||
|
||||
if (query.host !== response.host) {
|
||||
winston.warn(`[activitypub/resolveId] id resolution domain mismatch: ${query.href} != ${response.href}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return id;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
ActivityPub.resolveInboxes = async (ids) => {
|
||||
const inboxes = new Set();
|
||||
|
||||
if (!meta.config.activitypubAllowLoopback) {
|
||||
ids = ids.filter((id) => {
|
||||
const { hostname } = new URL(id);
|
||||
return hostname !== nconf.get('url_parsed').hostname;
|
||||
});
|
||||
}
|
||||
|
||||
await ActivityPub.actors.assert(ids);
|
||||
await batch.processArray(ids, async (currentIds) => {
|
||||
const usersData = await user.getUsersFields(currentIds, ['inbox', 'sharedInbox']);
|
||||
usersData.forEach((u) => {
|
||||
if (u && (u.sharedInbox || u.inbox)) {
|
||||
inboxes.add(u.sharedInbox || u.inbox);
|
||||
}
|
||||
});
|
||||
}, {
|
||||
batch: 500,
|
||||
});
|
||||
|
||||
return Array.from(inboxes);
|
||||
};
|
||||
|
||||
ActivityPub.getPublicKey = async (type, id) => {
|
||||
let publicKey;
|
||||
|
||||
try {
|
||||
({ publicKey } = await db.getObject(`${type}:${id}:keys`));
|
||||
} catch (e) {
|
||||
({ publicKey } = await ActivityPub.helpers.generateKeys(type, id));
|
||||
}
|
||||
|
||||
return publicKey;
|
||||
};
|
||||
|
||||
ActivityPub.getPrivateKey = async (type, id) => {
|
||||
// Sanity checking
|
||||
if (!['cid', 'uid'].includes(type) || !utils.isNumber(id) || parseInt(id, 10) < 0) {
|
||||
throw new Error('[[error:invalid-data]]');
|
||||
}
|
||||
id = parseInt(id, 10);
|
||||
let privateKey;
|
||||
|
||||
try {
|
||||
({ privateKey } = await db.getObject(`${type}:${id}:keys`));
|
||||
} catch (e) {
|
||||
({ privateKey } = await ActivityPub.helpers.generateKeys(type, id));
|
||||
}
|
||||
|
||||
let keyId;
|
||||
if (type === 'uid') {
|
||||
keyId = `${nconf.get('url')}${id > 0 ? `/uid/${id}` : '/actor'}#key`;
|
||||
} else {
|
||||
keyId = `${nconf.get('url')}/category/${id}#key`;
|
||||
}
|
||||
|
||||
return { key: privateKey, keyId };
|
||||
};
|
||||
|
||||
ActivityPub.fetchPublicKey = async (uri) => {
|
||||
// Used for retrieving the public key from the passed-in keyId uri
|
||||
const body = await ActivityPub.get('uid', 0, uri);
|
||||
|
||||
if (!body.hasOwnProperty('publicKey')) {
|
||||
throw new Error('[[error:activitypub.pubKey-not-found]]');
|
||||
}
|
||||
|
||||
return body.publicKey;
|
||||
};
|
||||
|
||||
ActivityPub.sign = async ({ key, keyId }, url, payload) => {
|
||||
// Returns string for use in 'Signature' header
|
||||
const { host, pathname } = new URL(url);
|
||||
const date = new Date().toUTCString();
|
||||
let digest = null;
|
||||
|
||||
let headers = '(request-target) host date';
|
||||
let signed_string = `(request-target): ${payload ? 'post' : 'get'} ${pathname}\nhost: ${host}\ndate: ${date}`;
|
||||
|
||||
// Calculate payload hash if payload present
|
||||
if (payload) {
|
||||
const payloadHash = createHash('sha256');
|
||||
payloadHash.update(JSON.stringify(payload));
|
||||
digest = `SHA-256=${payloadHash.digest('base64')}`;
|
||||
headers += ' digest';
|
||||
signed_string += `\ndigest: ${digest}`;
|
||||
}
|
||||
|
||||
// Sign string using private key
|
||||
let signature = createSign('sha256');
|
||||
signature.update(signed_string);
|
||||
signature.end();
|
||||
signature = signature.sign(key, 'base64');
|
||||
|
||||
// Construct signature header
|
||||
return {
|
||||
date,
|
||||
digest,
|
||||
signature: `keyId="${keyId}",headers="${headers}",signature="${signature}",algorithm="hs2019"`,
|
||||
};
|
||||
};
|
||||
|
||||
ActivityPub.verify = async (req) => {
|
||||
// winston.verbose('[activitypub/verify] Starting signature verification...');
|
||||
if (!req.headers.hasOwnProperty('signature')) {
|
||||
// winston.verbose('[activitypub/verify] Failed, no signature header.');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Break the signature apart
|
||||
let { keyId, headers, signature, algorithm, created, expires } = req.headers.signature.split(',').reduce((memo, cur) => {
|
||||
const split = cur.split('="');
|
||||
const key = split.shift();
|
||||
const value = split.join('="');
|
||||
memo[key] = value.slice(0, -1);
|
||||
return memo;
|
||||
}, {});
|
||||
|
||||
const acceptableHashes = getHashes();
|
||||
if (algorithm === 'hs2019' || !acceptableHashes.includes(algorithm)) {
|
||||
algorithm = 'sha256';
|
||||
}
|
||||
|
||||
// Re-construct signature string
|
||||
const signed_string = headers.split(' ').reduce((memo, cur) => {
|
||||
switch (cur) {
|
||||
case '(request-target)': {
|
||||
memo.push(`${cur}: ${String(req.method).toLowerCase()} ${req.baseUrl}${req.path}`);
|
||||
break;
|
||||
}
|
||||
|
||||
case '(created)': {
|
||||
memo.push(`${cur}: ${created}`);
|
||||
break;
|
||||
}
|
||||
|
||||
case '(expires)': {
|
||||
memo.push(`${cur}: ${expires}`);
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
memo.push(`${cur}: ${req.headers[cur]}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return memo;
|
||||
}, []).join('\n');
|
||||
|
||||
// Verify the signature string via public key
|
||||
try {
|
||||
// Retrieve public key from remote instance
|
||||
// winston.verbose(`[activitypub/verify] Retrieving pubkey for ${keyId}`);
|
||||
const { publicKeyPem } = await ActivityPub.fetchPublicKey(keyId);
|
||||
|
||||
const verify = createVerify('sha256');
|
||||
verify.update(signed_string);
|
||||
verify.end();
|
||||
// winston.verbose('[activitypub/verify] Attempting signed string verification');
|
||||
const verified = verify.verify(publicKeyPem, signature, 'base64');
|
||||
return verified;
|
||||
} catch (e) {
|
||||
// winston.verbose('[activitypub/verify] Failed, key retrieval or verification failure.');
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
ActivityPub.get = async (type, id, uri, options) => {
|
||||
options = {
|
||||
cache: true,
|
||||
...options,
|
||||
};
|
||||
const cacheKey = [id, uri].join(';');
|
||||
const cached = requestCache.get(cacheKey);
|
||||
if (options.cache && cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const keyData = await ActivityPub.getPrivateKey(type, id);
|
||||
const headers = id >= 0 ? await ActivityPub.sign(keyData, uri) : {};
|
||||
// winston.verbose(`[activitypub/get] ${uri}`);
|
||||
try {
|
||||
const { response, body } = await request.get(uri, {
|
||||
headers: {
|
||||
...headers,
|
||||
...options.headers,
|
||||
Accept: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
|
||||
},
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
if (!String(response.statusCode).startsWith('2')) {
|
||||
winston.verbose(`[activitypub/get] Received ${response.statusCode} when querying ${uri}`);
|
||||
if (body.hasOwnProperty('error')) {
|
||||
winston.verbose(`[activitypub/get] Error received: ${body.error}`);
|
||||
}
|
||||
|
||||
const e = new Error(`[[error:activitypub.get-failed]]`);
|
||||
e.code = `ap_get_${response.statusCode}`;
|
||||
throw e;
|
||||
}
|
||||
|
||||
requestCache.set(cacheKey, body);
|
||||
return body;
|
||||
} catch (e) {
|
||||
if (String(e.code).startsWith('ap_get_')) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Handle things like non-json body, etc.
|
||||
const { cause } = e;
|
||||
throw new Error(`[[error:activitypub.get-failed]]`, { cause });
|
||||
}
|
||||
};
|
||||
|
||||
ActivityPub.retryQueue = lru({ name: 'activitypub-retry-queue', max: 4000, ttl: 1000 * 60 * 60 * 24 * 60 });
|
||||
|
||||
// handle clearing retry queue from another member of the cluster
|
||||
pubsub.on(`activitypub-retry-queue:lruCache:del`, (keys) => {
|
||||
if (Array.isArray(keys)) {
|
||||
keys.forEach(key => clearTimeout(ActivityPub.retryQueue.get(key)));
|
||||
}
|
||||
});
|
||||
|
||||
async function sendMessage(uri, id, type, payload, attempts = 1) {
|
||||
const keyData = await ActivityPub.getPrivateKey(type, id);
|
||||
const headers = await ActivityPub.sign(keyData, uri, payload);
|
||||
// winston.verbose(`[activitypub/send] ${uri}`);
|
||||
try {
|
||||
const { response, body } = await request.post(uri, {
|
||||
headers: {
|
||||
...headers,
|
||||
'content-type': 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
|
||||
},
|
||||
body: payload,
|
||||
});
|
||||
|
||||
if (String(response.statusCode).startsWith('2')) {
|
||||
// winston.verbose(`[activitypub/send] Successfully sent ${payload.type} to ${uri}`);
|
||||
} else {
|
||||
throw new Error(String(body));
|
||||
}
|
||||
} catch (e) {
|
||||
winston.warn(`[activitypub/send] Could not send ${payload.type} to ${uri}; error: ${e.message}`);
|
||||
// add to retry queue
|
||||
if (attempts < 12) { // stop attempting after ~2 months
|
||||
const timeout = (4 ** attempts) * 1000; // exponential backoff
|
||||
const queueId = `${payload.type}:${payload.id}:${new URL(uri).hostname}`;
|
||||
const timeoutId = setTimeout(() => sendMessage(uri, id, type, payload, attempts + 1), timeout);
|
||||
ActivityPub.retryQueue.set(queueId, timeoutId);
|
||||
|
||||
// winston.verbose(`[activitypub/send] Added ${payload.type} to ${uri} to retry queue for ${timeout}ms`);
|
||||
} else {
|
||||
winston.warn(`[activitypub/send] Max attempts reached for ${payload.type} to ${uri}; giving up on sending`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ActivityPub.send = async (type, id, targets, payload) => {
|
||||
if (!Array.isArray(targets)) {
|
||||
targets = [targets];
|
||||
}
|
||||
|
||||
const inboxes = await ActivityPub.resolveInboxes(targets);
|
||||
|
||||
const actor = ActivityPub.helpers.resolveActor(type, id);
|
||||
|
||||
payload = {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
actor,
|
||||
...payload,
|
||||
};
|
||||
|
||||
await batch.processArray(
|
||||
inboxes,
|
||||
async inboxBatch => Promise.all(inboxBatch.map(async uri => sendMessage(uri, id, type, payload))),
|
||||
{
|
||||
batch: 50,
|
||||
interval: 100,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
ActivityPub.record = async ({ id, type, actor }) => {
|
||||
const now = Date.now();
|
||||
const { hostname } = new URL(actor);
|
||||
|
||||
await Promise.all([
|
||||
db.sortedSetAdd(`activities:datetime`, now, id),
|
||||
db.sortedSetAdd('domains:lastSeen', now, hostname),
|
||||
analytics.increment(['activities', `activities:byType:${type}`, `activities:byHost:${hostname}`]),
|
||||
]);
|
||||
};
|
||||
19
src/activitypub/instances.js
Normal file
19
src/activitypub/instances.js
Normal file
@@ -0,0 +1,19 @@
|
||||
'use strict';
|
||||
|
||||
const meta = require('../meta');
|
||||
const db = require('../database');
|
||||
|
||||
const Instances = module.exports;
|
||||
|
||||
Instances.log = async (domain) => {
|
||||
await db.sortedSetAdd('instances:lastSeen', Date.now(), domain);
|
||||
};
|
||||
|
||||
Instances.getCount = async () => db.sortedSetCard('instances:lastSeen');
|
||||
|
||||
Instances.isAllowed = async (domain) => {
|
||||
let { activitypubFilter: type, activitypubFilterList: list } = meta.config;
|
||||
list = new Set(String(list).split('\n'));
|
||||
// eslint-disable-next-line no-bitwise
|
||||
return list.has(domain) ^ !type;
|
||||
};
|
||||
444
src/activitypub/mocks.js
Normal file
444
src/activitypub/mocks.js
Normal file
@@ -0,0 +1,444 @@
|
||||
'use strict';
|
||||
|
||||
const nconf = require('nconf');
|
||||
const mime = require('mime');
|
||||
const path = require('path');
|
||||
const sanitize = require('sanitize-html');
|
||||
|
||||
const meta = require('../meta');
|
||||
const user = require('../user');
|
||||
const categories = require('../categories');
|
||||
const posts = require('../posts');
|
||||
const topics = require('../topics');
|
||||
const plugins = require('../plugins');
|
||||
const slugify = require('../slugify');
|
||||
const utils = require('../utils');
|
||||
|
||||
const activitypub = module.parent.exports;
|
||||
const Mocks = module.exports;
|
||||
|
||||
/**
|
||||
* A more restrictive html sanitization run on top of standard sanitization from core.
|
||||
* Done so the output HTML is stripped of all non-essential items; mainly classes from plugins..
|
||||
*/
|
||||
const sanitizeConfig = {
|
||||
allowedTags: sanitize.defaults.allowedTags.concat(['img']),
|
||||
allowedClasses: {
|
||||
'*': [],
|
||||
},
|
||||
};
|
||||
|
||||
Mocks.profile = async (actors, hostMap) => {
|
||||
// Should only ever be called by activitypub.actors.assert
|
||||
const profiles = await Promise.all(actors.map(async (actor) => {
|
||||
if (!actor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const uid = actor.id;
|
||||
let hostname = hostMap.get(uid);
|
||||
let {
|
||||
url, preferredUsername, published, icon, image,
|
||||
name, summary, followers, inbox, endpoints,
|
||||
} = actor;
|
||||
preferredUsername = slugify(preferredUsername || name);
|
||||
const { followers: followerCount, following: followingCount } = await activitypub.actors.getLocalFollowCounts(uid);
|
||||
|
||||
if (!hostname) { // if not available via webfinger, infer from id
|
||||
try {
|
||||
({ hostname } = new URL(actor.id));
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
let picture;
|
||||
if (icon) {
|
||||
picture = typeof icon === 'string' ? icon : icon.url;
|
||||
}
|
||||
const iconBackgrounds = await user.getIconBackgrounds();
|
||||
let bgColor = Array.prototype.reduce.call(preferredUsername, (cur, next) => cur + next.charCodeAt(), 0);
|
||||
bgColor = iconBackgrounds[bgColor % iconBackgrounds.length];
|
||||
|
||||
const payload = {
|
||||
uid,
|
||||
username: `${preferredUsername}@${hostname}`,
|
||||
userslug: `${preferredUsername}@${hostname}`,
|
||||
displayname: name,
|
||||
fullname: name,
|
||||
joindate: new Date(published).getTime() || Date.now(),
|
||||
picture,
|
||||
status: 'offline',
|
||||
'icon:text': (preferredUsername[0] || '').toUpperCase(),
|
||||
'icon:bgColor': bgColor,
|
||||
uploadedpicture: undefined,
|
||||
'cover:url': !image || typeof image === 'string' ? image : image.url,
|
||||
'cover:position': '50% 50%',
|
||||
aboutme: summary,
|
||||
followerCount,
|
||||
followingCount,
|
||||
|
||||
url,
|
||||
inbox,
|
||||
sharedInbox: endpoints ? endpoints.sharedInbox : null,
|
||||
followersUrl: followers,
|
||||
};
|
||||
|
||||
return payload;
|
||||
}));
|
||||
|
||||
return profiles;
|
||||
};
|
||||
|
||||
Mocks.post = async (objects) => {
|
||||
let single = false;
|
||||
if (!Array.isArray(objects)) {
|
||||
single = true;
|
||||
objects = [objects];
|
||||
}
|
||||
|
||||
const actorIds = new Set(objects.map(object => object.attributedTo).filter(Boolean));
|
||||
await activitypub.actors.assert(Array.from(actorIds));
|
||||
|
||||
const posts = await Promise.all(objects.map(async (object) => {
|
||||
if (!activitypub._constants.acceptedPostTypes.includes(object.type)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let {
|
||||
id: pid,
|
||||
url,
|
||||
attributedTo: uid,
|
||||
inReplyTo: toPid,
|
||||
published, updated, name, content, source,
|
||||
to, cc, audience, attachment, tag,
|
||||
// conversation, // mastodon-specific, ignored.
|
||||
} = object;
|
||||
|
||||
const resolved = await activitypub.helpers.resolveLocalId(toPid);
|
||||
if (resolved.type === 'post') {
|
||||
toPid = resolved.id;
|
||||
}
|
||||
const timestamp = new Date(published).getTime();
|
||||
let edited = new Date(updated);
|
||||
edited = Number.isNaN(edited.valueOf()) ? undefined : edited;
|
||||
|
||||
if (content && content.length) {
|
||||
content = sanitize(content, sanitizeConfig);
|
||||
content = await activitypub.helpers.remoteAnchorToLocalProfile(content);
|
||||
} else {
|
||||
content = '<em>This post did not contain any content.</em>';
|
||||
}
|
||||
|
||||
const payload = {
|
||||
uid,
|
||||
pid,
|
||||
// tid, --> purposely omitted
|
||||
name,
|
||||
content,
|
||||
sourceContent: source && source.mediaType === 'text/markdown' ? source.content : undefined,
|
||||
timestamp,
|
||||
toPid,
|
||||
|
||||
edited,
|
||||
editor: edited ? uid : undefined,
|
||||
_activitypub: { to, cc, audience, attachment, tag, url },
|
||||
};
|
||||
|
||||
return payload;
|
||||
}));
|
||||
|
||||
return single ? posts.pop() : posts;
|
||||
};
|
||||
|
||||
Mocks.actors = {};
|
||||
|
||||
Mocks.actors.user = async (uid) => {
|
||||
let { username, userslug, displayname, fullname, joindate, aboutme, picture, 'cover:url': cover } = await user.getUserData(uid);
|
||||
const publicKey = await activitypub.getPublicKey('uid', uid);
|
||||
|
||||
if (picture) {
|
||||
const imagePath = await user.getLocalAvatarPath(uid);
|
||||
picture = {
|
||||
type: 'Image',
|
||||
mediaType: mime.getType(imagePath),
|
||||
url: `${nconf.get('url')}${picture}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (cover) {
|
||||
const imagePath = await user.getLocalCoverPath(uid);
|
||||
cover = {
|
||||
type: 'Image',
|
||||
mediaType: mime.getType(imagePath),
|
||||
url: `${nconf.get('url')}${cover}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
'@context': [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
'https://w3id.org/security/v1',
|
||||
],
|
||||
id: `${nconf.get('url')}/uid/${uid}`,
|
||||
url: `${nconf.get('url')}/user/${userslug}`,
|
||||
followers: `${nconf.get('url')}/uid/${uid}/followers`,
|
||||
following: `${nconf.get('url')}/uid/${uid}/following`,
|
||||
inbox: `${nconf.get('url')}/uid/${uid}/inbox`,
|
||||
outbox: `${nconf.get('url')}/uid/${uid}/outbox`,
|
||||
|
||||
type: 'Person',
|
||||
name: username !== displayname ? fullname : username, // displayname is escaped, fullname is not
|
||||
preferredUsername: userslug,
|
||||
summary: aboutme,
|
||||
icon: picture,
|
||||
image: cover,
|
||||
published: new Date(joindate).toISOString(),
|
||||
|
||||
publicKey: {
|
||||
id: `${nconf.get('url')}/uid/${uid}#key`,
|
||||
owner: `${nconf.get('url')}/uid/${uid}`,
|
||||
publicKeyPem: publicKey,
|
||||
},
|
||||
|
||||
endpoints: {
|
||||
sharedInbox: `${nconf.get('url')}/inbox`,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
Mocks.actors.category = async (cid) => {
|
||||
const {
|
||||
name, handle: preferredUsername, slug,
|
||||
descriptionParsed: summary, backgroundImage,
|
||||
} = await categories.getCategoryData(cid);
|
||||
const publicKey = await activitypub.getPublicKey('cid', cid);
|
||||
|
||||
let image;
|
||||
if (backgroundImage) {
|
||||
const filename = path.basename(utils.decodeHTMLEntities(backgroundImage));
|
||||
image = {
|
||||
type: 'Image',
|
||||
mediaType: mime.getType(filename),
|
||||
url: `${nconf.get('url')}${utils.decodeHTMLEntities(backgroundImage)}`,
|
||||
};
|
||||
}
|
||||
|
||||
let icon = meta.config['brand:logo'] || `${nconf.get('relative_path')}/assets/logo.png`;
|
||||
const filename = path.basename(utils.decodeHTMLEntities(icon));
|
||||
icon = {
|
||||
type: 'Image',
|
||||
mediaType: mime.getType(filename),
|
||||
url: `${nconf.get('url')}${utils.decodeHTMLEntities(icon)}`,
|
||||
};
|
||||
|
||||
return {
|
||||
'@context': [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
'https://w3id.org/security/v1',
|
||||
],
|
||||
id: `${nconf.get('url')}/category/${cid}`,
|
||||
url: `${nconf.get('url')}/category/${slug}`,
|
||||
// followers: ,
|
||||
// following: ,
|
||||
inbox: `${nconf.get('url')}/category/${cid}/inbox`,
|
||||
outbox: `${nconf.get('url')}/category/${cid}/outbox`,
|
||||
|
||||
type: 'Group',
|
||||
name,
|
||||
preferredUsername,
|
||||
summary,
|
||||
image,
|
||||
icon,
|
||||
|
||||
publicKey: {
|
||||
id: `${nconf.get('url')}/category/${cid}#key`,
|
||||
owner: `${nconf.get('url')}/category/${cid}`,
|
||||
publicKeyPem: publicKey,
|
||||
},
|
||||
|
||||
endpoints: {
|
||||
sharedInbox: `${nconf.get('url')}/inbox`,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
Mocks.note = async (post) => {
|
||||
const id = `${nconf.get('url')}/post/${post.pid}`;
|
||||
|
||||
// Return a tombstone for a deleted post
|
||||
if (post.deleted === true) {
|
||||
return Mocks.tombstone({
|
||||
id,
|
||||
formerType: 'Note',
|
||||
attributedTo: `${nconf.get('url')}/uid/${post.user.uid}`,
|
||||
context: `${nconf.get('url')}/topic/${post.topic.tid}`,
|
||||
audience: `${nconf.get('url')}/category/${post.category.cid}`,
|
||||
});
|
||||
}
|
||||
|
||||
const published = new Date(parseInt(post.timestamp, 10)).toISOString();
|
||||
|
||||
// todo: post visibility
|
||||
const to = new Set([activitypub._constants.publicAddress]);
|
||||
const cc = new Set([`${nconf.get('url')}/uid/${post.user.uid}/followers`]);
|
||||
|
||||
let inReplyTo = null;
|
||||
let tag = null;
|
||||
let followersUrl;
|
||||
|
||||
let name = null;
|
||||
({ titleRaw: name } = await topics.getTopicFields(post.tid, ['title']));
|
||||
|
||||
if (post.toPid) { // direct reply
|
||||
inReplyTo = utils.isNumber(post.toPid) ? `${nconf.get('url')}/post/${post.toPid}` : post.toPid;
|
||||
name = `Re: ${name}`;
|
||||
|
||||
const parentId = await posts.getPostField(post.toPid, 'uid');
|
||||
followersUrl = await user.getUserField(parentId, 'followersUrl');
|
||||
to.add(utils.isNumber(parentId) ? `${nconf.get('url')}/uid/${parentId}` : parentId);
|
||||
} else if (!post.isMainPost) { // reply to OP
|
||||
inReplyTo = utils.isNumber(post.topic.mainPid) ? `${nconf.get('url')}/post/${post.topic.mainPid}` : post.topic.mainPid;
|
||||
name = `Re: ${name}`;
|
||||
|
||||
to.add(utils.isNumber(post.topic.uid) ? `${nconf.get('url')}/uid/${post.topic.uid}` : post.topic.uid);
|
||||
followersUrl = await user.getUserField(post.topic.uid, 'followersUrl');
|
||||
} else { // new topic
|
||||
tag = post.topic.tags.map(tag => ({
|
||||
type: 'Hashtag',
|
||||
href: `${nconf.get('url')}/tags/${tag.valueEncoded}`,
|
||||
name: `#${tag.value}`,
|
||||
}));
|
||||
}
|
||||
|
||||
if (followersUrl) {
|
||||
cc.add(followersUrl);
|
||||
}
|
||||
|
||||
const content = await posts.getPostField(post.pid, 'content');
|
||||
post.content = content; // re-send raw content
|
||||
const parsed = await posts.parsePost(post, 'activitypub.note');
|
||||
post.content = sanitize(parsed.content, sanitizeConfig);
|
||||
post.content = posts.relativeToAbsolute(post.content, posts.urlRegex);
|
||||
post.content = posts.relativeToAbsolute(post.content, posts.imgRegex);
|
||||
|
||||
let source = null;
|
||||
const [markdownEnabled, mentionsEnabled] = await Promise.all([
|
||||
plugins.isActive('nodebb-plugin-markdown'),
|
||||
plugins.isActive('nodebb-plugin-mentions'),
|
||||
]);
|
||||
if (markdownEnabled) {
|
||||
const raw = await posts.getPostField(post.pid, 'content');
|
||||
source = {
|
||||
content: raw,
|
||||
mediaType: 'text/markdown',
|
||||
};
|
||||
}
|
||||
if (mentionsEnabled) {
|
||||
const mentions = require.main.require('nodebb-plugin-mentions');
|
||||
const matches = await mentions.getMatches(post.content);
|
||||
|
||||
if (matches.size) {
|
||||
tag = tag || [];
|
||||
tag.push(...Array.from(matches).map(({ type, id: href, slug: name }) => {
|
||||
if (utils.isNumber(href)) { // local ref
|
||||
name = name.toLowerCase(); // local slugs are always lowercase
|
||||
href = `${nconf.get('url')}/${type === 'uid' ? 'user' : `category/${href}`}/${name.slice(1)}`;
|
||||
name = `${name}@${nconf.get('url_parsed').hostname}`;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'Mention',
|
||||
href,
|
||||
name,
|
||||
};
|
||||
}));
|
||||
|
||||
Array.from(matches)
|
||||
.reduce((ids, { id }) => {
|
||||
if (!utils.isNumber(id) && !to.has(id) && !cc.has(id)) {
|
||||
ids.push(id);
|
||||
}
|
||||
|
||||
return ids;
|
||||
}, [])
|
||||
.forEach(id => cc.add(id));
|
||||
}
|
||||
}
|
||||
|
||||
let attachment = await posts.attachments.get(post.pid) || [];
|
||||
const uploads = await posts.uploads.listWithSizes(post.pid);
|
||||
uploads.forEach(({ name, width, height }) => {
|
||||
const mediaType = mime.getType(name);
|
||||
const url = `${nconf.get('url') + nconf.get('upload_url')}/${name}`;
|
||||
attachment.push({ mediaType, url, width, height });
|
||||
});
|
||||
|
||||
// Inspect post content for external imagery as well
|
||||
let match = posts.imgRegex.regex.exec(post.content);
|
||||
while (match !== null) {
|
||||
if (match[1]) {
|
||||
const { hostname, pathname, href: url } = new URL(match[1]);
|
||||
if (hostname !== nconf.get('url_parsed').hostname) {
|
||||
const mediaType = mime.getType(pathname);
|
||||
attachment.push({ mediaType, url });
|
||||
}
|
||||
}
|
||||
match = posts.imgRegex.regex.exec(post.content);
|
||||
}
|
||||
|
||||
attachment = attachment.map(({ mediaType, url, width, height }) => {
|
||||
let type;
|
||||
|
||||
switch (true) {
|
||||
case mediaType.startsWith('image'): {
|
||||
type = 'Image';
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
type = 'Link';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const payload = { type, mediaType, url };
|
||||
|
||||
if (width || height) {
|
||||
payload.width = width;
|
||||
payload.height = height;
|
||||
}
|
||||
|
||||
return payload;
|
||||
});
|
||||
|
||||
const object = {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
id,
|
||||
type: 'Note',
|
||||
to: Array.from(to),
|
||||
cc: Array.from(cc),
|
||||
inReplyTo,
|
||||
published,
|
||||
url: id,
|
||||
attributedTo: `${nconf.get('url')}/uid/${post.user.uid}`,
|
||||
context: `${nconf.get('url')}/topic/${post.topic.tid}`,
|
||||
audience: `${nconf.get('url')}/category/${post.category.cid}`,
|
||||
sensitive: false, // todo
|
||||
summary: null,
|
||||
name,
|
||||
content: post.content,
|
||||
source,
|
||||
tag,
|
||||
attachment,
|
||||
replies: `${id}/replies`,
|
||||
};
|
||||
|
||||
return object;
|
||||
};
|
||||
|
||||
Mocks.tombstone = async properties => ({
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
type: 'Tombstone',
|
||||
...properties,
|
||||
});
|
||||
450
src/activitypub/notes.js
Normal file
450
src/activitypub/notes.js
Normal file
@@ -0,0 +1,450 @@
|
||||
'use strict';
|
||||
|
||||
const winston = require('winston');
|
||||
const nconf = require('nconf');
|
||||
|
||||
const db = require('../database');
|
||||
const batch = require('../batch');
|
||||
const meta = require('../meta');
|
||||
const privileges = require('../privileges');
|
||||
const categories = require('../categories');
|
||||
const user = require('../user');
|
||||
const topics = require('../topics');
|
||||
const posts = require('../posts');
|
||||
const utils = require('../utils');
|
||||
|
||||
const activitypub = module.parent.exports;
|
||||
const Notes = module.exports;
|
||||
|
||||
async function lock(value) {
|
||||
const count = await db.incrObjectField('locks', value);
|
||||
return count <= 1;
|
||||
}
|
||||
|
||||
async function unlock(value) {
|
||||
await db.deleteObjectField('locks', value);
|
||||
}
|
||||
|
||||
Notes.assert = async (uid, input, options = { skipChecks: false }) => {
|
||||
/**
|
||||
* Given the id or object of any as:Note, either retrieves the full context (if resolvable),
|
||||
* or traverses up the reply chain to build a context.
|
||||
*/
|
||||
|
||||
if (!input) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id = !activitypub.helpers.isUri(input) ? input.id : input;
|
||||
const lockStatus = await lock(id, '[[error:activitypub.already-asserting]]');
|
||||
if (!lockStatus) { // unable to achieve lock, stop processing.
|
||||
return null;
|
||||
}
|
||||
|
||||
let chain;
|
||||
const context = await activitypub.contexts.get(uid, id);
|
||||
if (context.tid) {
|
||||
unlock(id);
|
||||
const { tid } = context;
|
||||
return { tid, count: 0 };
|
||||
} else if (context.context) {
|
||||
chain = Array.from(await activitypub.contexts.getItems(uid, context.context, { input }));
|
||||
} else {
|
||||
// Fall back to inReplyTo traversal
|
||||
chain = Array.from(await Notes.getParentChain(uid, input));
|
||||
}
|
||||
if (!chain.length) {
|
||||
unlock(id);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Reorder chain items by timestamp
|
||||
chain = chain.sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
const mainPost = chain[0];
|
||||
let { pid: mainPid, tid, uid: authorId, timestamp, name, content, _activitypub } = mainPost;
|
||||
const hasTid = !!tid;
|
||||
|
||||
const cid = hasTid ? await topics.getTopicField(tid, 'cid') : options.cid || -1;
|
||||
if (options.cid && cid === -1) {
|
||||
// Move topic if currently uncategorized
|
||||
await topics.tools.move(tid, { cid: options.cid, uid: 'system' });
|
||||
}
|
||||
|
||||
const members = await db.isSortedSetMembers(`tid:${tid}:posts`, chain.slice(1).map(p => p.pid));
|
||||
members.unshift(await posts.exists(mainPid));
|
||||
if (tid && members.every(Boolean)) {
|
||||
// All cached, return early.
|
||||
// winston.verbose('[notes/assert] No new notes to process.');
|
||||
unlock(id);
|
||||
return { tid, count: 0 };
|
||||
}
|
||||
|
||||
let title;
|
||||
if (hasTid) {
|
||||
mainPid = await topics.getTopicField(tid, 'mainPid');
|
||||
} else {
|
||||
// Check recipients/audience for local category
|
||||
const set = activitypub.helpers.makeSet(_activitypub, ['to', 'cc', 'audience']);
|
||||
const resolved = await Promise.all(Array.from(set).map(async id => await activitypub.helpers.resolveLocalId(id)));
|
||||
const recipientCids = resolved
|
||||
.filter(Boolean)
|
||||
.filter(({ type }) => type === 'category')
|
||||
.map(obj => obj.id);
|
||||
if (recipientCids.length) {
|
||||
// Overrides passed-in value, respect addressing from main post over booster
|
||||
options.cid = recipientCids.shift();
|
||||
}
|
||||
|
||||
// mainPid ok to leave as-is
|
||||
title = name || activitypub.helpers.generateTitle(utils.decodeHTMLEntities(content));
|
||||
}
|
||||
mainPid = utils.isNumber(mainPid) ? parseInt(mainPid, 10) : mainPid;
|
||||
|
||||
// Relation & privilege check for local categories
|
||||
const inputIndex = chain.map(n => n.pid).indexOf(id);
|
||||
const hasRelation =
|
||||
uid || hasTid ||
|
||||
options.skipChecks || options.cid ||
|
||||
await assertRelation(chain[inputIndex || 0]);
|
||||
const privilege = `topics:${tid ? 'reply' : 'create'}`;
|
||||
const allowed = await privileges.categories.can(privilege, cid, activitypub._constants.uid);
|
||||
if (!hasRelation || !allowed) {
|
||||
if (!hasRelation) {
|
||||
winston.info(`[activitypub/notes.assert] Not asserting ${id} as it has no relation to existing tracked content.`);
|
||||
}
|
||||
|
||||
unlock(id);
|
||||
return null;
|
||||
}
|
||||
|
||||
tid = tid || utils.generateUUID();
|
||||
mainPost.tid = tid;
|
||||
|
||||
const urlMap = chain.reduce((map, post) => (post.url ? map.set(post.url, post.id) : map), new Map());
|
||||
const unprocessed = chain.map((post) => {
|
||||
post.tid = tid; // add tid to post hash
|
||||
|
||||
// Ensure toPids in replies are ids
|
||||
if (urlMap.has(post.toPid)) {
|
||||
post.toPid = urlMap.get(post.toPid);
|
||||
}
|
||||
|
||||
return post;
|
||||
}).filter((p, idx) => !members[idx]);
|
||||
const count = unprocessed.length;
|
||||
// winston.verbose(`[notes/assert] ${count} new note(s) found.`);
|
||||
|
||||
let tags;
|
||||
if (!hasTid) {
|
||||
const { to, cc, attachment } = mainPost._activitypub;
|
||||
const systemTags = (meta.config.systemTags || '').split(',');
|
||||
const maxTags = await categories.getCategoryField(cid, 'maxTags');
|
||||
tags = (mainPost._activitypub.tag || [])
|
||||
.filter(o => o.type === 'Hashtag' && !systemTags.includes(o.name.slice(1)))
|
||||
.map(o => o.name.slice(1));
|
||||
|
||||
if (tags.length > maxTags) {
|
||||
tags.length = maxTags;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
topics.post({
|
||||
tid,
|
||||
uid: authorId,
|
||||
cid,
|
||||
pid: mainPid,
|
||||
title,
|
||||
timestamp,
|
||||
tags,
|
||||
content: mainPost.content,
|
||||
_activitypub: mainPost._activitypub,
|
||||
}),
|
||||
Notes.updateLocalRecipients(mainPid, { to, cc }),
|
||||
posts.attachments.update(mainPid, attachment),
|
||||
]);
|
||||
unprocessed.shift();
|
||||
}
|
||||
|
||||
for (const post of unprocessed) {
|
||||
const { to, cc, attachment } = post._activitypub;
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await topics.reply(post);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await Promise.all([
|
||||
Notes.updateLocalRecipients(post.pid, { to, cc }),
|
||||
posts.attachments.update(post.pid, attachment),
|
||||
]);
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
Notes.syncUserInboxes(tid, uid),
|
||||
unlock(id),
|
||||
]);
|
||||
|
||||
return { tid, count };
|
||||
};
|
||||
|
||||
async function assertRelation(post) {
|
||||
/**
|
||||
* Given a mocked post object, ensures that it is related to some other object in database
|
||||
* This check ensures that random content isn't added to the database just because it is received.
|
||||
*/
|
||||
|
||||
// Is followed by at least one local user
|
||||
const { followers } = await activitypub.actors.getLocalFollowCounts(post.uid);
|
||||
|
||||
// Local user is mentioned
|
||||
const { tag } = post._activitypub;
|
||||
let uids = [];
|
||||
if (tag && tag.length) {
|
||||
const slugs = tag.reduce((slugs, tag) => {
|
||||
if (tag.type === 'Mention') {
|
||||
const [slug, hostname] = tag.name.slice(1).split('@');
|
||||
if (hostname === nconf.get('url_parsed').hostname) {
|
||||
slugs.push(slug);
|
||||
}
|
||||
}
|
||||
return slugs;
|
||||
}, []);
|
||||
|
||||
uids = slugs.length ? await db.sortedSetScores('userslug:uid', slugs) : [];
|
||||
uids = uids.filter(Boolean);
|
||||
}
|
||||
|
||||
return followers > 0 || uids.length;
|
||||
}
|
||||
|
||||
Notes.updateLocalRecipients = async (id, { to, cc }) => {
|
||||
const recipients = new Set([...(to || []), ...(cc || [])]);
|
||||
const uids = new Set();
|
||||
await Promise.all(Array.from(recipients).map(async (recipient) => {
|
||||
const { type, id } = await activitypub.helpers.resolveLocalId(recipient);
|
||||
if (type === 'user' && await user.exists(id)) {
|
||||
uids.add(parseInt(id, 10));
|
||||
return;
|
||||
}
|
||||
|
||||
const followedUid = await db.getObjectField('followersUrl:uid', recipient);
|
||||
if (followedUid) {
|
||||
const { uids: followers } = await activitypub.actors.getLocalFollowers(followedUid);
|
||||
if (followers.size > 0) {
|
||||
followers.forEach((uid) => {
|
||||
uids.add(uid);
|
||||
});
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
if (uids.size > 0) {
|
||||
await db.setAdd(`post:${id}:recipients`, Array.from(uids));
|
||||
}
|
||||
};
|
||||
|
||||
Notes.getParentChain = async (uid, input) => {
|
||||
// Traverse upwards via `inReplyTo` until you find the root-level Note
|
||||
const id = activitypub.helpers.isUri(input) ? input : input.id;
|
||||
|
||||
const chain = new Set();
|
||||
const traverse = async (uid, id) => {
|
||||
// Handle remote reference to local post
|
||||
const { type, id: localId } = await activitypub.helpers.resolveLocalId(id);
|
||||
if (type === 'post' && localId) {
|
||||
return await traverse(uid, localId);
|
||||
}
|
||||
|
||||
const postData = await posts.getPostData(id);
|
||||
if (postData) {
|
||||
chain.add(postData);
|
||||
if (postData.toPid) {
|
||||
await traverse(uid, postData.toPid);
|
||||
} else if (utils.isNumber(id)) { // local pid without toPid, could be OP or reply to OP
|
||||
const mainPid = await topics.getTopicField(postData.tid, 'mainPid');
|
||||
if (mainPid !== parseInt(id, 10)) {
|
||||
await traverse(uid, mainPid);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let object = !activitypub.helpers.isUri(input) && input.id === id ? input : undefined;
|
||||
try {
|
||||
object = object || await activitypub.get('uid', uid, id);
|
||||
|
||||
// Handle incorrect id passed in
|
||||
if (id !== object.id) {
|
||||
return await traverse(uid, object.id);
|
||||
}
|
||||
|
||||
object = await activitypub.mocks.post(object);
|
||||
if (object) {
|
||||
chain.add(object);
|
||||
if (object.toPid) {
|
||||
await traverse(uid, object.toPid);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
winston.verbose(`[activitypub/notes/getParentChain] Cannot retrieve ${id}, terminating here.`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await traverse(uid, id);
|
||||
return chain;
|
||||
};
|
||||
|
||||
Notes.syncUserInboxes = async function (tid, uid) {
|
||||
const [pids, { cid, mainPid }] = await Promise.all([
|
||||
db.getSortedSetMembers(`tid:${tid}:posts`),
|
||||
topics.getTopicFields(tid, ['tid', 'cid', 'mainPid']),
|
||||
]);
|
||||
pids.unshift(mainPid);
|
||||
|
||||
const recipients = await db.getSetsMembers(pids.map(id => `post:${id}:recipients`));
|
||||
const uids = recipients.reduce((set, uids) => new Set([...set, ...uids.map(u => parseInt(u, 10))]), new Set());
|
||||
if (uid) {
|
||||
uids.add(parseInt(uid, 10));
|
||||
}
|
||||
|
||||
const keys = Array.from(uids).map(uid => `uid:${uid}:inbox`);
|
||||
const score = await db.sortedSetScore(`cid:${cid}:tids`, tid);
|
||||
|
||||
const removeKeys = (await db.getSetMembers(`tid:${tid}:recipients`))
|
||||
.filter(uid => !uids.has(parseInt(uid, 10)))
|
||||
.map((uid => `uid:${uid}:inbox`));
|
||||
|
||||
// winston.verbose(`[activitypub/syncUserInboxes] Syncing tid ${tid} with ${uids.size} inboxes`);
|
||||
await Promise.all([
|
||||
db.sortedSetsRemove(removeKeys, tid),
|
||||
db.sortedSetsAdd(keys, keys.map(() => score || Date.now()), tid),
|
||||
db.setAdd(`tid:${tid}:recipients`, Array.from(uids)),
|
||||
]);
|
||||
};
|
||||
|
||||
Notes.getCategoryFollowers = async (cid) => {
|
||||
// Retrieves remote users who have followed a category; used to build recipient list
|
||||
let uids = await db.getSortedSetRangeByScore(`cid:${cid}:uid:watch:state`, 0, -1, categories.watchStates.tracking, categories.watchStates.tracking);
|
||||
uids = uids.filter(uid => !utils.isNumber(uid));
|
||||
|
||||
return uids;
|
||||
};
|
||||
|
||||
Notes.announce = {};
|
||||
|
||||
Notes.announce.list = async ({ pid, tid }) => {
|
||||
let pids = [];
|
||||
if (pid) {
|
||||
pids = [pid];
|
||||
} else if (tid) {
|
||||
let mainPid;
|
||||
([pids, mainPid] = await Promise.all([
|
||||
db.getSortedSetMembers(`tid:${tid}:posts`),
|
||||
topics.getTopicField(tid, 'mainPid'),
|
||||
]));
|
||||
pids.unshift(mainPid);
|
||||
}
|
||||
|
||||
if (!pids.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const keys = pids.map(pid => `pid:${pid}:announces`);
|
||||
let announces = await db.getSortedSetsMembersWithScores(keys);
|
||||
announces = announces.reduce((memo, cur, idx) => {
|
||||
if (cur.length) {
|
||||
const pid = pids[idx];
|
||||
cur.forEach(({ value: actor, score: timestamp }) => {
|
||||
memo.push({ pid, actor, timestamp });
|
||||
});
|
||||
}
|
||||
return memo;
|
||||
}, []);
|
||||
|
||||
return announces;
|
||||
};
|
||||
|
||||
Notes.announce.add = async (pid, actor, timestamp = Date.now()) => {
|
||||
await db.sortedSetAdd(`pid:${pid}:announces`, timestamp, actor);
|
||||
await posts.setPostField(pid, 'announces', await db.sortedSetCard(`pid:${pid}:announces`));
|
||||
};
|
||||
|
||||
Notes.announce.remove = async (pid, actor) => {
|
||||
await db.sortedSetRemove(`pid:${pid}:announces`, actor);
|
||||
const count = await db.sortedSetCard(`pid:${pid}:announces`);
|
||||
if (count > 0) {
|
||||
await posts.setPostField(pid, 'announces', count);
|
||||
} else {
|
||||
await db.deleteObjectField(`post:${pid}`, 'announces');
|
||||
}
|
||||
};
|
||||
|
||||
Notes.announce.removeAll = async (pid) => {
|
||||
await Promise.all([
|
||||
db.delete(`pid:${pid}:announces`),
|
||||
db.deleteObjectField(`post:${pid}`, 'announces'),
|
||||
]);
|
||||
};
|
||||
|
||||
Notes.delete = async (pids) => {
|
||||
if (!Array.isArray(pids)) {
|
||||
pids = [pids];
|
||||
}
|
||||
|
||||
const exists = await posts.exists(pids);
|
||||
pids = pids.filter((_, idx) => exists[idx]);
|
||||
|
||||
let tids = await posts.getPostsFields(pids, ['tid']);
|
||||
tids = new Set(tids.map(obj => obj.tid));
|
||||
|
||||
const recipientSets = pids.map(id => `post:${id}:recipients`);
|
||||
const announcerSets = pids.map(id => `pid:${id}:announces`);
|
||||
|
||||
await db.deleteAll([...recipientSets, ...announcerSets]);
|
||||
await Promise.all(Array.from(tids).map(async tid => Notes.syncUserInboxes(tid)));
|
||||
};
|
||||
|
||||
Notes.prune = async () => {
|
||||
/**
|
||||
* Prune topics in cid -1 that have received no engagement.
|
||||
* Engagement is defined as:
|
||||
* - Replied to (contains a local reply)
|
||||
* - Post within is liked
|
||||
*/
|
||||
winston.info('[notes/prune] Starting scheduled pruning of topics');
|
||||
const start = '-inf';
|
||||
const stop = Date.now() - (1000 * 60 * 60 * 24 * 30); // 30 days; todo: make configurable?
|
||||
let tids = await db.getSortedSetRangeByScore('cid:-1:tids', 0, -1, start, stop);
|
||||
|
||||
winston.info(`[notes/prune] Found ${tids.length} topics older than 30 days (since last activity).`);
|
||||
|
||||
const posters = await db.getSortedSetsMembers(tids.map(tid => `tid:${tid}:posters`));
|
||||
const hasLocalVoter = await Promise.all(tids.map(async (tid) => {
|
||||
const mainPid = await db.getObjectField(`topic:${tid}`, 'mainPid');
|
||||
const pids = await db.getSortedSetMembers(`tid:${tid}:posts`);
|
||||
pids.unshift(mainPid);
|
||||
|
||||
// Check voters of each pid for a local uid
|
||||
const voters = new Set();
|
||||
await Promise.all(pids.map(async (pid) => {
|
||||
const [upvoters, downvoters] = await db.getSetsMembers([`pid:${pid}:upvote`, `pid:${pid}:downvote`]);
|
||||
upvoters.forEach(uid => voters.add(uid));
|
||||
downvoters.forEach(uid => voters.add(uid));
|
||||
}));
|
||||
|
||||
return Array.from(voters).some(uid => utils.isNumber(uid));
|
||||
}));
|
||||
|
||||
tids = tids.filter((_, idx) => {
|
||||
const localPoster = posters[idx].some(uid => utils.isNumber(uid));
|
||||
const localVoter = hasLocalVoter[idx];
|
||||
|
||||
return !localPoster && !localVoter;
|
||||
});
|
||||
|
||||
winston.info(`[notes/prune] ${tids.length} topics eligible for pruning`);
|
||||
|
||||
await batch.processArray(tids, async (tids) => {
|
||||
await Promise.all(tids.map(async tid => await topics.purgePostsAndTopic(tid, 0)));
|
||||
}, { batch: 100 });
|
||||
|
||||
winston.info('[notes/prune] Scheduled pruning of topics complete.');
|
||||
};
|
||||
@@ -90,6 +90,8 @@ Analytics.increment = function (keys, callback) {
|
||||
}
|
||||
};
|
||||
|
||||
Analytics.peek = () => local;
|
||||
|
||||
Analytics.getKeys = async () => db.getSortedSetRange('analyticsKeys', 0, -1);
|
||||
|
||||
Analytics.pageView = async function (payload) {
|
||||
|
||||
408
src/api/activitypub.js
Normal file
408
src/api/activitypub.js
Normal file
@@ -0,0 +1,408 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* DEVELOPMENT NOTE
|
||||
*
|
||||
* THIS FILE IS UNDER ACTIVE DEVELOPMENT AND IS EXPLICITLY EXCLUDED FROM IMMUTABILITY GUARANTEES
|
||||
*
|
||||
* If you use api methods in this file, be prepared that they may be removed or modified with no warning.
|
||||
*/
|
||||
|
||||
const nconf = require('nconf');
|
||||
const winston = require('winston');
|
||||
|
||||
const db = require('../database');
|
||||
const user = require('../user');
|
||||
const meta = require('../meta');
|
||||
const privileges = require('../privileges');
|
||||
const activitypub = require('../activitypub');
|
||||
const posts = require('../posts');
|
||||
const topics = require('../topics');
|
||||
const utils = require('../utils');
|
||||
|
||||
const activitypubApi = module.exports;
|
||||
|
||||
function enabledCheck(next) {
|
||||
return async function (caller, params) {
|
||||
if (meta.config.activitypubEnabled) {
|
||||
try {
|
||||
await next(caller, params);
|
||||
} catch (e) {
|
||||
winston.error(`[activitypub/api] Error\n${e.stack}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
activitypubApi.follow = enabledCheck(async (caller, { type, id, actor } = {}) => {
|
||||
// Privilege checks should be done upstream
|
||||
const assertion = await activitypub.actors.assert(actor);
|
||||
if (!assertion) {
|
||||
throw new Error('[[error:activitypub.invalid-id]]');
|
||||
}
|
||||
|
||||
actor = actor.includes('@') ? await user.getUidByUserslug(actor) : actor;
|
||||
const handle = await user.getUserField(actor, 'username');
|
||||
const timestamp = Date.now();
|
||||
|
||||
await activitypub.send(type, id, [actor], {
|
||||
id: `${nconf.get('url')}/${type}/${id}#activity/follow/${handle}/${timestamp}`,
|
||||
type: 'Follow',
|
||||
object: actor,
|
||||
});
|
||||
|
||||
await db.sortedSetAdd(`followRequests:${type}.${id}`, timestamp, actor);
|
||||
});
|
||||
|
||||
// should be .undo.follow
|
||||
activitypubApi.unfollow = enabledCheck(async (caller, { type, id, actor }) => {
|
||||
const assertion = await activitypub.actors.assert(actor);
|
||||
if (!assertion) {
|
||||
throw new Error('[[error:activitypub.invalid-id]]');
|
||||
}
|
||||
|
||||
actor = actor.includes('@') ? await user.getUidByUserslug(actor) : actor;
|
||||
const handle = await user.getUserField(actor, 'username');
|
||||
const timestamps = await db.sortedSetsScore([
|
||||
`followRequests:${type}.${id}`,
|
||||
type === 'uid' ? `followingRemote:${id}` : `cid:${id}:following`,
|
||||
], actor);
|
||||
const timestamp = timestamps[0] || timestamps[1];
|
||||
|
||||
const object = {
|
||||
id: `${nconf.get('url')}/${type}/${id}#activity/follow/${handle}/${timestamp}`,
|
||||
type: 'Follow',
|
||||
object: actor,
|
||||
};
|
||||
if (type === 'uid') {
|
||||
object.actor = `${nconf.get('url')}/uid/${id}`;
|
||||
} else if (type === 'cid') {
|
||||
object.actor = `${nconf.get('url')}/category/${id}`;
|
||||
}
|
||||
|
||||
await activitypub.send(type, id, [actor], {
|
||||
id: `${nconf.get('url')}/${type}/${id}#activity/undo:follow/${handle}/${timestamp}`,
|
||||
type: 'Undo',
|
||||
object,
|
||||
});
|
||||
|
||||
if (type === 'uid') {
|
||||
await Promise.all([
|
||||
db.sortedSetRemove(`followingRemote:${id}`, actor),
|
||||
db.decrObjectField(`user:${id}`, 'followingRemoteCount'),
|
||||
]);
|
||||
} else if (type === 'cid') {
|
||||
await Promise.all([
|
||||
db.sortedSetRemove(`cid:${id}:following`, actor),
|
||||
db.sortedSetRemove(`followRequests:cid.${id}`, actor),
|
||||
db.sortedSetRemove(`followersRemote:${actor}`, `cid|${id}`),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
activitypubApi.create = {};
|
||||
|
||||
async function buildRecipients(object, { pid, uid }) {
|
||||
/**
|
||||
* - Builds a list of targets for activitypub.send to consume
|
||||
* - Extends to and cc since the activity can be addressed more widely
|
||||
* - `pid` is optional, but if included, includes announcers and all authors up the toPid chain
|
||||
*/
|
||||
const followers = await db.getSortedSetMembers(`followersRemote:${uid}`);
|
||||
let { to, cc } = object;
|
||||
to = new Set(to);
|
||||
cc = new Set(cc);
|
||||
|
||||
const followersUrl = `${nconf.get('url')}/uid/${uid}/followers`;
|
||||
if (!to.has(followersUrl)) {
|
||||
cc.add(followersUrl);
|
||||
}
|
||||
|
||||
const targets = new Set([...followers, ...to, ...cc]);
|
||||
|
||||
// Remove any ids that aren't asserted actors
|
||||
const exists = await db.isSortedSetMembers('usersRemote:lastCrawled', [...targets]);
|
||||
Array.from(targets).forEach((uri, idx) => {
|
||||
if (!exists[idx]) {
|
||||
targets.delete(uri);
|
||||
}
|
||||
});
|
||||
|
||||
// Topic posters, post announcers and their followers
|
||||
if (pid) {
|
||||
const tid = await posts.getPostField(pid, 'tid');
|
||||
const participants = (await db.getSortedSetMembers(`tid:${tid}:posters`))
|
||||
.filter(uid => !utils.isNumber(uid)); // remote users only
|
||||
const announcers = (await activitypub.notes.announce.list({ pid })).map(({ actor }) => actor);
|
||||
const auxiliaries = Array.from(new Set([...participants, ...announcers]));
|
||||
const auxiliaryFollowers = (await user.getUsersFields(auxiliaries, ['followersUrl']))
|
||||
.filter(o => o.hasOwnProperty('followersUrl'))
|
||||
.map(({ followersUrl }) => followersUrl);
|
||||
[...auxiliaries].forEach(uri => targets.add(uri));
|
||||
[...auxiliaries, ...auxiliaryFollowers].forEach(uri => cc.add(uri));
|
||||
}
|
||||
|
||||
return {
|
||||
to: [...to],
|
||||
cc: [...cc],
|
||||
targets,
|
||||
};
|
||||
}
|
||||
|
||||
activitypubApi.create.note = enabledCheck(async (caller, { pid, post }) => {
|
||||
if (!post) {
|
||||
post = (await posts.getPostSummaryByPids([pid], caller.uid, { stripTags: false })).pop();
|
||||
if (!post) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
pid = post.pid;
|
||||
}
|
||||
|
||||
const allowed = await privileges.posts.can('topics:read', pid, activitypub._constants.uid);
|
||||
if (!allowed) {
|
||||
// winston.verbose(`[activitypub/api] Not federating creation of pid ${pid} to the fediverse due to privileges.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const object = await activitypub.mocks.note(post);
|
||||
const { to, cc, targets } = await buildRecipients(object, { pid, uid: post.user.uid });
|
||||
const { cid } = post.category;
|
||||
const followers = await activitypub.notes.getCategoryFollowers(cid);
|
||||
|
||||
const payload = {
|
||||
id: `${object.id}#activity/create/${Date.now()}`,
|
||||
type: 'Create',
|
||||
to,
|
||||
cc,
|
||||
object,
|
||||
};
|
||||
|
||||
await activitypub.send('uid', caller.uid, Array.from(targets), payload);
|
||||
|
||||
if (followers.length) {
|
||||
setTimeout(() => { // Delay sending to avoid potential race condition
|
||||
Promise.all([payload, payload.object].map(async (object) => {
|
||||
await activitypub.send('cid', cid, followers, {
|
||||
id: `${nconf.get('url')}/post/${encodeURIComponent(object.object ? object.object.id : object.id)}#activity/announce/${Date.now()}`,
|
||||
type: 'Announce',
|
||||
to: [activitypub._constants.publicAddress],
|
||||
cc: [`${nconf.get('url')}/category/${cid}/followers`],
|
||||
object,
|
||||
});
|
||||
})).catch(err => winston.error(err.stack));
|
||||
}, 5000);
|
||||
}
|
||||
});
|
||||
|
||||
activitypubApi.update = {};
|
||||
|
||||
activitypubApi.update.profile = enabledCheck(async (caller, { uid }) => {
|
||||
const [object, targets] = await Promise.all([
|
||||
activitypub.mocks.actors.user(uid),
|
||||
db.getSortedSetMembers(`followersRemote:${caller.uid}`),
|
||||
]);
|
||||
|
||||
await activitypub.send('uid', caller.uid, targets, {
|
||||
id: `${object.id}#activity/update/${Date.now()}`,
|
||||
type: 'Update',
|
||||
to: [activitypub._constants.publicAddress],
|
||||
cc: [],
|
||||
object,
|
||||
});
|
||||
});
|
||||
|
||||
activitypubApi.update.category = enabledCheck(async (caller, { cid }) => {
|
||||
const [object, targets] = await Promise.all([
|
||||
activitypub.mocks.actors.category(cid),
|
||||
activitypub.notes.getCategoryFollowers(cid),
|
||||
]);
|
||||
|
||||
await activitypub.send('cid', cid, targets, {
|
||||
id: `${object.id}#activity/update/${Date.now()}`,
|
||||
type: 'Update',
|
||||
to: [activitypub._constants.publicAddress],
|
||||
cc: [],
|
||||
object,
|
||||
});
|
||||
});
|
||||
|
||||
activitypubApi.update.note = enabledCheck(async (caller, { post }) => {
|
||||
// Only applies to local posts
|
||||
if (!utils.isNumber(post.pid)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const object = await activitypub.mocks.note(post);
|
||||
const { to, cc, targets } = await buildRecipients(object, { pid: post.pid, uid: post.user.uid });
|
||||
|
||||
const allowed = await privileges.posts.can('topics:read', post.pid, activitypub._constants.uid);
|
||||
if (!allowed) {
|
||||
// winston.verbose(
|
||||
// `[activitypub/api] Not federating update of pid ${post.pid} to the fediverse due to privileges.`
|
||||
// );
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
id: `${object.id}#activity/update/${post.edited || Date.now()}`,
|
||||
type: 'Update',
|
||||
to,
|
||||
cc,
|
||||
object,
|
||||
};
|
||||
|
||||
await activitypub.send('uid', caller.uid, Array.from(targets), payload);
|
||||
});
|
||||
|
||||
activitypubApi.delete = {};
|
||||
|
||||
activitypubApi.delete.note = enabledCheck(async (caller, { pid }) => {
|
||||
// Only applies to local posts
|
||||
if (!utils.isNumber(pid)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = `${nconf.get('url')}/post/${pid}`;
|
||||
const post = (await posts.getPostSummaryByPids([pid], caller.uid, { stripTags: false })).pop();
|
||||
const object = await activitypub.mocks.note(post);
|
||||
const { to, cc, targets } = await buildRecipients(object, { pid, uid: post.user.uid });
|
||||
|
||||
const allowed = await privileges.posts.can('topics:read', pid, activitypub._constants.uid);
|
||||
if (!allowed) {
|
||||
// winston.verbose(`[activitypub/api] Not federating update of pid ${pid} to the fediverse due to privileges.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
id: `${id}#activity/delete/${Date.now()}`,
|
||||
type: 'Delete',
|
||||
to,
|
||||
cc,
|
||||
object: id,
|
||||
origin: object.context,
|
||||
};
|
||||
|
||||
await activitypub.send('uid', caller.uid, Array.from(targets), payload);
|
||||
});
|
||||
|
||||
activitypubApi.like = {};
|
||||
|
||||
activitypubApi.like.note = enabledCheck(async (caller, { pid }) => {
|
||||
if (!activitypub.helpers.isUri(pid)) { // remote only
|
||||
return;
|
||||
}
|
||||
|
||||
const uid = await posts.getPostField(pid, 'uid');
|
||||
if (!activitypub.helpers.isUri(uid)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await activitypub.send('uid', caller.uid, [uid], {
|
||||
id: `${nconf.get('url')}/uid/${caller.uid}#activity/like/${encodeURIComponent(pid)}`,
|
||||
type: 'Like',
|
||||
object: pid,
|
||||
});
|
||||
});
|
||||
|
||||
activitypubApi.announce = {};
|
||||
|
||||
activitypubApi.announce.note = enabledCheck(async (caller, { tid }) => {
|
||||
const { mainPid: pid, cid } = await topics.getTopicFields(tid, ['mainPid', 'cid']);
|
||||
|
||||
// Only remote posts can be announced to real categories
|
||||
if (utils.isNumber(pid) || parseInt(cid, 10) === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uid = await posts.getPostField(pid, 'uid'); // author
|
||||
const allowed = await privileges.posts.can('topics:read', pid, activitypub._constants.uid);
|
||||
if (!allowed) {
|
||||
// winston.verbose(`[activitypub/api] Not federating announce of pid ${pid} to the fediverse due to privileges.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { to, cc, targets } = await buildRecipients({
|
||||
id: pid,
|
||||
to: [activitypub._constants.publicAddress],
|
||||
cc: [`${nconf.get('url')}/uid/${caller.uid}/followers`, uid],
|
||||
}, { uid: caller.uid });
|
||||
|
||||
await activitypub.send('uid', caller.uid, Array.from(targets), {
|
||||
id: `${nconf.get('url')}/post/${encodeURIComponent(pid)}#activity/announce/${Date.now()}`,
|
||||
type: 'Announce',
|
||||
to,
|
||||
cc,
|
||||
object: pid,
|
||||
target: `${nconf.get('url')}/category/${cid}`,
|
||||
});
|
||||
});
|
||||
|
||||
activitypubApi.undo = {};
|
||||
|
||||
// activitypubApi.undo.follow =
|
||||
|
||||
activitypubApi.undo.like = enabledCheck(async (caller, { pid }) => {
|
||||
if (!activitypub.helpers.isUri(pid)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uid = await posts.getPostField(pid, 'uid');
|
||||
if (!activitypub.helpers.isUri(uid)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await activitypub.send('uid', caller.uid, [uid], {
|
||||
id: `${nconf.get('url')}/uid/${caller.uid}#activity/undo:like/${encodeURIComponent(pid)}/${Date.now()}`,
|
||||
type: 'Undo',
|
||||
object: {
|
||||
actor: `${nconf.get('url')}/uid/${caller.uid}`,
|
||||
id: `${nconf.get('url')}/uid/${caller.uid}#activity/like/${encodeURIComponent(pid)}`,
|
||||
type: 'Like',
|
||||
object: pid,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
activitypubApi.flag = enabledCheck(async (caller, flag) => {
|
||||
if (!activitypub.helpers.isUri(flag.targetId)) {
|
||||
return;
|
||||
}
|
||||
const reportedIds = [flag.targetId];
|
||||
if (flag.type === 'post' && activitypub.helpers.isUri(flag.targetUid)) {
|
||||
reportedIds.push(flag.targetUid);
|
||||
}
|
||||
const reason = flag.reason ||
|
||||
(flag.reports && flag.reports.filter(report => report.reporter.uid === caller.uid).at(-1).value);
|
||||
await activitypub.send('uid', caller.uid, reportedIds, {
|
||||
id: `${nconf.get('url')}/${flag.type}/${encodeURIComponent(flag.targetId)}#activity/flag/${caller.uid}`,
|
||||
type: 'Flag',
|
||||
object: reportedIds,
|
||||
content: reason,
|
||||
});
|
||||
await db.sortedSetAdd(`flag:${flag.flagId}:remote`, Date.now(), caller.uid);
|
||||
});
|
||||
|
||||
activitypubApi.undo.flag = enabledCheck(async (caller, flag) => {
|
||||
if (!activitypub.helpers.isUri(flag.targetId)) {
|
||||
return;
|
||||
}
|
||||
const reportedIds = [flag.targetId];
|
||||
if (flag.type === 'post' && activitypub.helpers.isUri(flag.targetUid)) {
|
||||
reportedIds.push(flag.targetUid);
|
||||
}
|
||||
const reason = flag.reason ||
|
||||
(flag.reports && flag.reports.filter(report => report.reporter.uid === caller.uid).at(-1).value);
|
||||
await activitypub.send('uid', caller.uid, reportedIds, {
|
||||
id: `${nconf.get('url')}/${flag.type}/${encodeURIComponent(flag.targetId)}#activity/undo:flag/${caller.uid}/${Date.now()}`,
|
||||
type: 'Undo',
|
||||
object: {
|
||||
id: `${nconf.get('url')}/${flag.type}/${encodeURIComponent(flag.targetId)}#activity/flag/${caller.uid}`,
|
||||
actor: `${nconf.get('url')}/uid/${caller.uid}`,
|
||||
type: 'Flag',
|
||||
object: reportedIds,
|
||||
content: reason,
|
||||
},
|
||||
});
|
||||
await db.sortedSetRemove(`flag:${flag.flagId}:remote`, caller.uid);
|
||||
});
|
||||
@@ -8,6 +8,8 @@ const user = require('../user');
|
||||
const groups = require('../groups');
|
||||
const privileges = require('../privileges');
|
||||
|
||||
const activitypubApi = require('./activitypub');
|
||||
|
||||
const categoriesAPI = module.exports;
|
||||
|
||||
const hasAdminPrivilege = async (uid, privilege = 'categories') => {
|
||||
@@ -63,6 +65,7 @@ categoriesAPI.update = async function (caller, data) {
|
||||
const payload = {};
|
||||
payload[cid] = values;
|
||||
await categories.update(payload);
|
||||
activitypubApi.update.category(caller, { cid }); // background
|
||||
};
|
||||
|
||||
categoriesAPI.delete = async function (caller, { cid }) {
|
||||
|
||||
@@ -11,7 +11,7 @@ flagsApi.create = async (caller, data) => {
|
||||
throw new Error('[[error:invalid-data]]');
|
||||
}
|
||||
|
||||
const { type, id, reason } = data;
|
||||
const { type, id, reason, notifyRemote } = data;
|
||||
|
||||
await flags.validate({
|
||||
uid: caller.uid,
|
||||
@@ -19,7 +19,7 @@ flagsApi.create = async (caller, data) => {
|
||||
id: id,
|
||||
});
|
||||
|
||||
const flagObj = await flags.create(type, id, caller.uid, reason);
|
||||
const flagObj = await flags.create(type, id, caller.uid, reason, undefined, undefined, notifyRemote);
|
||||
flags.notify(flagObj, caller.uid);
|
||||
|
||||
return flagObj;
|
||||
@@ -59,6 +59,24 @@ flagsApi.rescind = async ({ uid }, { flagId }) => {
|
||||
await flags.rescindReport(type, targetId, uid);
|
||||
};
|
||||
|
||||
flagsApi.rescindPost = async ({ uid }, { pid }) => {
|
||||
const exists = await flags.exists('post', pid, uid);
|
||||
if (!exists) {
|
||||
throw new Error('[[error:no-flag]]');
|
||||
}
|
||||
|
||||
await flags.rescindReport('post', pid, uid);
|
||||
};
|
||||
|
||||
flagsApi.rescindUser = async ({ uid }, { uid: targetUid }) => {
|
||||
const exists = await flags.exists('user', targetUid, uid);
|
||||
if (!exists) {
|
||||
throw new Error('[[error:no-flag]]');
|
||||
}
|
||||
|
||||
await flags.rescindReport('user', targetUid, uid);
|
||||
};
|
||||
|
||||
flagsApi.appendNote = async (caller, data) => {
|
||||
const allowed = await user.isPrivileged(caller.uid);
|
||||
if (!allowed) {
|
||||
|
||||
@@ -129,6 +129,7 @@ exports.postCommand = async function (caller, command, eventName, notification,
|
||||
};
|
||||
|
||||
async function executeCommand(caller, command, eventName, notification, data) {
|
||||
const api = require('.');
|
||||
const result = await posts[command](data.pid, caller.uid);
|
||||
if (result && eventName) {
|
||||
websockets.in(`uid_${caller.uid}`).emit(`posts.${command}`, result);
|
||||
@@ -136,10 +137,12 @@ async function executeCommand(caller, command, eventName, notification, data) {
|
||||
}
|
||||
if (result && command === 'upvote') {
|
||||
socketHelpers.upvote(result, notification);
|
||||
api.activitypub.like.note(caller, { pid: data.pid });
|
||||
} else if (result && notification) {
|
||||
socketHelpers.sendNotificationToPostOwner(data.pid, caller.uid, command, notification);
|
||||
} else if (result && command === 'unvote') {
|
||||
socketHelpers.rescindUpvoteNotification(data.pid, caller.uid);
|
||||
api.activitypub.undo.like(caller, { pid: data.pid });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ module.exports = {
|
||||
categories: require('./categories'),
|
||||
search: require('./search'),
|
||||
flags: require('./flags'),
|
||||
activitypub: require('./activitypub'),
|
||||
files: require('./files'),
|
||||
utils: require('./utils'),
|
||||
};
|
||||
|
||||
@@ -7,13 +7,13 @@ const db = require('../database');
|
||||
const utils = require('../utils');
|
||||
const user = require('../user');
|
||||
const posts = require('../posts');
|
||||
const postsCache = require('../posts/cache');
|
||||
const topics = require('../topics');
|
||||
const groups = require('../groups');
|
||||
const plugins = require('../plugins');
|
||||
const meta = require('../meta');
|
||||
const events = require('../events');
|
||||
const privileges = require('../privileges');
|
||||
const activitypub = require('../activitypub');
|
||||
const apiHelpers = require('./helpers');
|
||||
const websockets = require('../socket.io');
|
||||
const socketHelpers = require('../socket.io/helpers');
|
||||
@@ -90,17 +90,25 @@ postsAPI.edit = async function (caller, data) {
|
||||
if (!caller.uid) {
|
||||
throw new Error('[[error:not-logged-in]]');
|
||||
}
|
||||
// Trim and remove HTML (latter for composers that send in HTML, like redactor)
|
||||
const contentLen = utils.stripHTMLTags(data.content).trim().length;
|
||||
|
||||
// Discard content for non-local posts
|
||||
if (!utils.isNumber(data.pid)) {
|
||||
data.content = null;
|
||||
} else {
|
||||
// Trim and remove HTML (latter for composers that send in HTML, like redactor)
|
||||
const contentLen = utils.stripHTMLTags(data.content).trim().length;
|
||||
|
||||
if (meta.config.minimumPostLength !== 0 && contentLen < meta.config.minimumPostLength) {
|
||||
throw new Error(`[[error:content-too-short, ${meta.config.minimumPostLength}]]`);
|
||||
} else if (contentLen > meta.config.maximumPostLength) {
|
||||
throw new Error(`[[error:content-too-long, ${meta.config.maximumPostLength}]]`);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.title && data.title.length < meta.config.minimumTitleLength) {
|
||||
throw new Error(`[[error:title-too-short, ${meta.config.minimumTitleLength}]]`);
|
||||
} else if (data.title && data.title.length > meta.config.maximumTitleLength) {
|
||||
throw new Error(`[[error:title-too-long, ${meta.config.maximumTitleLength}]]`);
|
||||
} else if (meta.config.minimumPostLength !== 0 && contentLen < meta.config.minimumPostLength) {
|
||||
throw new Error(`[[error:content-too-short, ${meta.config.minimumPostLength}]]`);
|
||||
} else if (contentLen > meta.config.maximumPostLength) {
|
||||
throw new Error(`[[error:content-too-long, ${meta.config.maximumPostLength}]]`);
|
||||
} else if (!await posts.canUserPostContentWithLinks(caller.uid, data.content)) {
|
||||
throw new Error(`[[error:not-enough-reputation-to-post-links, ${meta.config['min:rep:post-links']}]]`);
|
||||
}
|
||||
@@ -135,12 +143,17 @@ postsAPI.edit = async function (caller, data) {
|
||||
newTitle: validator.escape(String(editResult.topic.title)),
|
||||
});
|
||||
}
|
||||
const postObj = await posts.getPostSummaryByPids([editResult.post.pid], caller.uid, {});
|
||||
const postObj = await posts.getPostSummaryByPids([editResult.post.pid], caller.uid, { parse: false, extraFields: ['edited'] });
|
||||
postObj.content = editResult.post.content; // re-use already parsed html
|
||||
const returnData = { ...postObj[0], ...editResult.post };
|
||||
returnData.topic = { ...postObj[0].topic, ...editResult.post.topic };
|
||||
|
||||
if (!editResult.post.deleted) {
|
||||
websockets.in(`topic_${editResult.topic.tid}`).emit('event:post_edited', editResult);
|
||||
setTimeout(() => {
|
||||
require('.').activitypub.update.note(caller, { post: postObj[0] });
|
||||
}, 5000);
|
||||
|
||||
return returnData;
|
||||
}
|
||||
|
||||
@@ -153,6 +166,7 @@ postsAPI.edit = async function (caller, data) {
|
||||
|
||||
const uids = _.uniq(_.flatten(memberData).concat(String(caller.uid)));
|
||||
uids.forEach(uid => websockets.in(`uid_${uid}`).emit('event:post_edited', editResult));
|
||||
|
||||
return returnData;
|
||||
};
|
||||
|
||||
@@ -191,6 +205,11 @@ async function deleteOrRestore(caller, data, params) {
|
||||
tid: postData.tid,
|
||||
ip: caller.ip,
|
||||
});
|
||||
|
||||
// Explicitly non-awaited
|
||||
posts.getPostSummaryByPids([data.pid], caller.uid, {}).then(([post]) => {
|
||||
require('.').activitypub.update.note(caller, { post });
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteOrRestoreTopicOf(command, pid, caller) {
|
||||
@@ -209,16 +228,22 @@ async function deleteOrRestoreTopicOf(command, pid, caller) {
|
||||
}
|
||||
|
||||
postsAPI.purge = async function (caller, data) {
|
||||
if (!data || !parseInt(data.pid, 10)) {
|
||||
if (!data || !data.pid) {
|
||||
throw new Error('[[error:invalid-data]]');
|
||||
}
|
||||
|
||||
const results = await isMainAndLastPost(data.pid);
|
||||
if (results.isMain && !results.isLast) {
|
||||
const [exists, { isMain, isLast }] = await Promise.all([
|
||||
posts.exists(data.pid),
|
||||
isMainAndLastPost(data.pid),
|
||||
]);
|
||||
if (!exists) {
|
||||
throw new Error('[[error:no-post]]');
|
||||
}
|
||||
if (isMain && !isLast) {
|
||||
throw new Error('[[error:cant-purge-main-post]]');
|
||||
}
|
||||
|
||||
const isMainAndLast = results.isMain && results.isLast;
|
||||
const isMainAndLast = isMain && isLast;
|
||||
const postData = await posts.getPostFields(data.pid, ['toPid', 'tid']);
|
||||
postData.pid = data.pid;
|
||||
|
||||
@@ -226,8 +251,11 @@ postsAPI.purge = async function (caller, data) {
|
||||
if (!canPurge) {
|
||||
throw new Error('[[error:no-privileges]]');
|
||||
}
|
||||
postsCache.del(data.pid);
|
||||
await posts.purge(data.pid, caller.uid);
|
||||
posts.clearCachedPost(data.pid);
|
||||
await Promise.all([
|
||||
posts.purge(data.pid, caller.uid),
|
||||
require('.').activitypub.delete.note(caller, { pid: data.pid }),
|
||||
]);
|
||||
|
||||
websockets.in(`topic_${postData.tid}`).emit('event:post_purged', postData);
|
||||
const topicData = await topics.getTopicFields(postData.tid, ['title', 'cid']);
|
||||
@@ -355,9 +383,13 @@ postsAPI.getUpvoters = async function (caller, data) {
|
||||
throw new Error('[[error:no-privileges]]');
|
||||
}
|
||||
|
||||
let upvotedUids = (await posts.getUpvotedUidsByPids([pid]))[0];
|
||||
const upvotedUids = (await posts.getUpvotedUidsByPids([pid]))[0];
|
||||
return await getTooltipData(upvotedUids);
|
||||
};
|
||||
|
||||
async function getTooltipData(uids) {
|
||||
const cutoff = 6;
|
||||
if (!upvotedUids.length) {
|
||||
if (!uids.length) {
|
||||
return {
|
||||
otherCount: 0,
|
||||
usernames: [],
|
||||
@@ -365,17 +397,41 @@ postsAPI.getUpvoters = async function (caller, data) {
|
||||
};
|
||||
}
|
||||
let otherCount = 0;
|
||||
if (upvotedUids.length > cutoff) {
|
||||
otherCount = upvotedUids.length - (cutoff - 1);
|
||||
upvotedUids = upvotedUids.slice(0, cutoff - 1);
|
||||
if (uids.length > cutoff) {
|
||||
otherCount = uids.length - (cutoff - 1);
|
||||
uids = uids.slice(0, cutoff - 1);
|
||||
}
|
||||
|
||||
const usernames = await user.getUsernamesByUids(upvotedUids);
|
||||
const usernames = await user.getUsernamesByUids(uids);
|
||||
return {
|
||||
otherCount,
|
||||
usernames,
|
||||
cutoff,
|
||||
};
|
||||
}
|
||||
|
||||
postsAPI.getAnnouncers = async (caller, data) => {
|
||||
if (!data.pid) {
|
||||
throw new Error('[[error:invalid-data]]');
|
||||
}
|
||||
if (!meta.config.activitypubEnabled) {
|
||||
return [];
|
||||
}
|
||||
const { pid } = data;
|
||||
const cid = await posts.getCidByPid(pid);
|
||||
if (!await privileges.categories.isUserAllowedTo('topics:read', cid, caller.uid)) {
|
||||
throw new Error('[[error:no-privileges]]');
|
||||
}
|
||||
const notes = require('../activitypub/notes');
|
||||
const announcers = await notes.announce.list({ pid });
|
||||
const uids = announcers.map(ann => ann.actor);
|
||||
if (data.tooltip) {
|
||||
return await getTooltipData(uids);
|
||||
}
|
||||
return {
|
||||
announceCount: uids.length,
|
||||
announcers: await user.getUsersFields(uids, ['username', 'userslug', 'picture']),
|
||||
};
|
||||
};
|
||||
|
||||
async function canSeeVotes(uid, cids, type) {
|
||||
@@ -489,7 +545,7 @@ postsAPI.deleteDiff = async (caller, { pid, timestamp }) => {
|
||||
};
|
||||
|
||||
postsAPI.getReplies = async (caller, { pid }) => {
|
||||
if (!utils.isNumber(pid)) {
|
||||
if (!utils.isNumber(pid) && !activitypub.helpers.isUri(pid)) {
|
||||
throw new Error('[[error:invalid-data]]');
|
||||
}
|
||||
const { uid } = caller;
|
||||
|
||||
@@ -29,6 +29,9 @@ searchApi.categories = async (caller, data) => {
|
||||
({ cids, matchedCids } = await findMatchedCids(caller.uid, data));
|
||||
} else {
|
||||
cids = await loadCids(caller.uid, data.parentCid);
|
||||
if (meta.config.activitypubEnabled) {
|
||||
cids.unshift(-1);
|
||||
}
|
||||
}
|
||||
|
||||
const visibleCategories = await controllersHelpers.getVisibleCategories({
|
||||
|
||||
@@ -11,6 +11,7 @@ const privileges = require('../privileges');
|
||||
const events = require('../events');
|
||||
const batch = require('../batch');
|
||||
|
||||
const activitypubApi = require('./activitypub');
|
||||
const apiHelpers = require('./helpers');
|
||||
|
||||
const { doTopicAction } = apiHelpers;
|
||||
@@ -83,6 +84,12 @@ topicsAPI.create = async function (caller, data) {
|
||||
socketHelpers.emitToUids('event:new_topic', result.topicData, [caller.uid]);
|
||||
socketHelpers.notifyNew(caller.uid, 'newTopic', { posts: [result.postData], topic: result.topicData });
|
||||
|
||||
if (!isScheduling) {
|
||||
setTimeout(() => {
|
||||
activitypubApi.create.note(caller, { pid: result.postData.pid });
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
return result.topicData;
|
||||
};
|
||||
|
||||
@@ -100,7 +107,6 @@ topicsAPI.reply = async function (caller, data) {
|
||||
}
|
||||
|
||||
const postData = await topics.reply(payload); // postData seems to be a subset of postObj, refactor?
|
||||
const postObj = await posts.getPostSummaryByPids([postData.pid], caller.uid, {});
|
||||
|
||||
const result = {
|
||||
posts: [postData],
|
||||
@@ -116,8 +122,9 @@ topicsAPI.reply = async function (caller, data) {
|
||||
}
|
||||
|
||||
socketHelpers.notifyNew(caller.uid, 'newPost', result);
|
||||
activitypubApi.create.note(caller, { post: postData });
|
||||
|
||||
return postObj[0];
|
||||
return postData;
|
||||
};
|
||||
|
||||
topicsAPI.delete = async function (caller, data) {
|
||||
@@ -331,6 +338,7 @@ topicsAPI.move = async (caller, { tid, cid }) => {
|
||||
socketHelpers.emitToUids('event:topic_moved', topicData, notifyUids);
|
||||
if (!topicData.deleted) {
|
||||
socketHelpers.sendNotificationToTopicOwner(tid, caller.uid, 'move', 'notifications:moved-your-topic');
|
||||
activitypubApi.announce.note(caller, { tid });
|
||||
}
|
||||
|
||||
await events.log({
|
||||
|
||||
@@ -175,27 +175,11 @@ usersAPI.changePassword = async function (caller, data) {
|
||||
|
||||
usersAPI.follow = async function (caller, data) {
|
||||
await user.follow(caller.uid, data.uid);
|
||||
await user.onFollow(caller.uid, data.uid);
|
||||
plugins.hooks.fire('action:user.follow', {
|
||||
fromUid: caller.uid,
|
||||
toUid: data.uid,
|
||||
});
|
||||
|
||||
const userData = await user.getUserFields(caller.uid, ['username', 'userslug']);
|
||||
const { displayname } = userData;
|
||||
|
||||
const notifObj = await notifications.create({
|
||||
type: 'follow',
|
||||
bodyShort: `[[notifications:user-started-following-you, ${displayname}]]`,
|
||||
nid: `follow:${data.uid}:uid:${caller.uid}`,
|
||||
from: caller.uid,
|
||||
path: `/uid/${caller.uid}`,
|
||||
mergeId: 'notifications:user-started-following-you',
|
||||
});
|
||||
if (!notifObj) {
|
||||
return;
|
||||
}
|
||||
notifObj.user = userData;
|
||||
await notifications.push(notifObj, [data.uid]);
|
||||
};
|
||||
|
||||
usersAPI.unfollow = async function (caller, data) {
|
||||
@@ -531,7 +515,7 @@ async function isPrivilegedOrSelfAndPasswordMatch(caller, data) {
|
||||
|
||||
async function processDeletion({ uid, method, password, caller }) {
|
||||
const isTargetAdmin = await user.isAdministrator(uid);
|
||||
const isSelf = parseInt(uid, 10) === parseInt(caller.uid, 10);
|
||||
const isSelf = String(uid) === String(caller.uid);
|
||||
const hasAdminPrivilege = await privileges.admin.can('admin:users', caller.uid);
|
||||
|
||||
if (isSelf && meta.config.allowAccountDelete !== 1) {
|
||||
|
||||
8
src/cache/lru.js
vendored
8
src/cache/lru.js
vendored
@@ -57,6 +57,14 @@ module.exports = function (opts) {
|
||||
});
|
||||
});
|
||||
|
||||
cache.has = function (key) {
|
||||
if (!cache.enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return lruCache.has(key);
|
||||
};
|
||||
|
||||
cache.set = function (key, value, ttl) {
|
||||
if (!cache.enabled) {
|
||||
return;
|
||||
|
||||
2
src/cache/ttl.js
vendored
2
src/cache/ttl.js
vendored
@@ -30,7 +30,7 @@ module.exports = function (opts) {
|
||||
});
|
||||
});
|
||||
|
||||
cache.has = (key) => {
|
||||
cache.has = function (key) {
|
||||
if (!cache.enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ const _ = require('lodash');
|
||||
|
||||
const db = require('../database');
|
||||
const plugins = require('../plugins');
|
||||
const meta = require('../meta');
|
||||
const privileges = require('../privileges');
|
||||
const utils = require('../utils');
|
||||
const slugify = require('../slugify');
|
||||
@@ -20,6 +21,7 @@ module.exports = function (Categories) {
|
||||
|
||||
data.name = String(data.name || `Category ${cid}`);
|
||||
const slug = `${cid}/${slugify(data.name)}`;
|
||||
const handle = await Categories.generateHandle(slugify(data.name));
|
||||
const smallestOrder = firstChild.length ? firstChild[0].score - 1 : 1;
|
||||
const order = data.order || smallestOrder; // If no order provided, place it at the top
|
||||
const colours = Categories.assignColours();
|
||||
@@ -27,6 +29,7 @@ module.exports = function (Categories) {
|
||||
let category = {
|
||||
cid: cid,
|
||||
name: data.name,
|
||||
handle,
|
||||
description: data.description ? data.description : '',
|
||||
descriptionParsed: data.descriptionParsed ? data.descriptionParsed : '',
|
||||
icon: data.icon ? data.icon : '',
|
||||
@@ -91,7 +94,7 @@ module.exports = function (Categories) {
|
||||
['categories:name', 0, `${data.name.slice(0, 200).toLowerCase()}:${category.cid}`],
|
||||
]);
|
||||
|
||||
await privileges.categories.give(result.defaultPrivileges, category.cid, 'registered-users');
|
||||
await privileges.categories.give(result.defaultPrivileges, category.cid, ['registered-users', 'fediverse']);
|
||||
await privileges.categories.give(result.modPrivileges, category.cid, ['administrators', 'Global Moderators']);
|
||||
await privileges.categories.give(result.guestPrivileges, category.cid, ['guests', 'spiders']);
|
||||
|
||||
@@ -146,6 +149,19 @@ module.exports = function (Categories) {
|
||||
await async.each(children, Categories.create);
|
||||
}
|
||||
|
||||
async function generateHandle(slug) {
|
||||
let taken = await meta.slugTaken(slug);
|
||||
let suffix;
|
||||
while (taken) {
|
||||
suffix = utils.generateUUID().slice(0, 8);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
taken = await meta.slugTaken(`${slug}-${suffix}`);
|
||||
}
|
||||
|
||||
return `${slug}${suffix ? `-${suffix}` : ''}`;
|
||||
}
|
||||
Categories.generateHandle = generateHandle; // exported for upgrade script (4.0.0)
|
||||
|
||||
Categories.assignColours = function () {
|
||||
const backgrounds = ['#AB4642', '#DC9656', '#F7CA88', '#A1B56C', '#86C1B9', '#7CAFC2', '#BA8BAF', '#A16946'];
|
||||
const text = ['#ffffff', '#ffffff', '#333333', '#ffffff', '#333333', '#ffffff', '#ffffff', '#ffffff'];
|
||||
|
||||
@@ -13,14 +13,45 @@ const intFields = [
|
||||
'minTags', 'maxTags', 'postQueue', 'subCategoriesPerPage',
|
||||
];
|
||||
|
||||
const worldCategory = {
|
||||
cid: -1,
|
||||
name: 'Uncategorized',
|
||||
description: 'Topics that do not strictly fit in with any existing categories',
|
||||
icon: 'fa-globe',
|
||||
imageClass: 'cover',
|
||||
bgColor: '#eee',
|
||||
color: '#333',
|
||||
slug: '../world',
|
||||
parentCid: 0,
|
||||
disabled: 0,
|
||||
handle: 'world',
|
||||
link: '',
|
||||
class: '', // todo
|
||||
};
|
||||
worldCategory.descriptionParsed = worldCategory.description;
|
||||
|
||||
module.exports = function (Categories) {
|
||||
Categories.getCategoriesFields = async function (cids, fields) {
|
||||
if (!Array.isArray(cids) || !cids.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
cids = cids.map(cid => parseInt(cid, 10));
|
||||
const keys = cids.map(cid => `category:${cid}`);
|
||||
const categories = await db.getObjects(keys, fields);
|
||||
|
||||
// Handle cid -1
|
||||
if (cids.includes(-1)) {
|
||||
let subset = null;
|
||||
if (fields && fields.length) {
|
||||
subset = fields.reduce((category, field) => {
|
||||
category[field] = worldCategory[field] || undefined;
|
||||
return category;
|
||||
}, {});
|
||||
}
|
||||
categories.splice(cids.indexOf(-1), 1, subset || { ...worldCategory });
|
||||
}
|
||||
|
||||
const result = await plugins.hooks.fire('filter:category.getFields', {
|
||||
cids: cids,
|
||||
categories: categories,
|
||||
|
||||
@@ -30,6 +30,13 @@ Categories.exists = async function (cids) {
|
||||
);
|
||||
};
|
||||
|
||||
Categories.existsByHandle = async function (handle) {
|
||||
if (Array.isArray(handle)) {
|
||||
return await db.isSortedSetMembers('categoryhandle:cid', handle);
|
||||
}
|
||||
return await db.isSortedSetMember('categoryhandle:cid', handle);
|
||||
};
|
||||
|
||||
Categories.getCategoryById = async function (data) {
|
||||
const categories = await Categories.getCategories([data.cid]);
|
||||
if (!categories[0]) {
|
||||
@@ -39,7 +46,7 @@ Categories.getCategoryById = async function (data) {
|
||||
data.category = category;
|
||||
|
||||
const promises = [
|
||||
Categories.getCategoryTopics(data),
|
||||
data.cid !== '-1' ? Categories.getCategoryTopics(data) : [],
|
||||
Categories.getTopicCount(data),
|
||||
Categories.getWatchState([data.cid], data.uid),
|
||||
getChildrenTree(category, data.uid),
|
||||
@@ -67,6 +74,10 @@ Categories.getCategoryById = async function (data) {
|
||||
return { ...result.category };
|
||||
};
|
||||
|
||||
Categories.getCidByHandle = async function (handle) {
|
||||
return await db.sortedSetScore('categoryhandle:cid', handle);
|
||||
};
|
||||
|
||||
Categories.getAllCidsFromSet = async function (key) {
|
||||
let cids = cache.get(key);
|
||||
if (cids) {
|
||||
@@ -86,6 +97,10 @@ Categories.getAllCategories = async function () {
|
||||
|
||||
Categories.getCidsByPrivilege = async function (set, uid, privilege) {
|
||||
const cids = await Categories.getAllCidsFromSet(set);
|
||||
if (set === 'categories:cid') {
|
||||
cids.unshift(-1);
|
||||
}
|
||||
|
||||
return await privileges.categories.filterCids(privilege, cids, uid);
|
||||
};
|
||||
|
||||
|
||||
@@ -49,6 +49,8 @@ module.exports = function (Categories) {
|
||||
return await updateTagWhitelist(cid, value);
|
||||
} else if (key === 'name') {
|
||||
return await updateName(cid, value);
|
||||
} else if (key === 'handle') {
|
||||
return await updateHandle(cid, value);
|
||||
} else if (key === 'order') {
|
||||
return await updateOrder(cid, value);
|
||||
}
|
||||
@@ -142,4 +144,22 @@ module.exports = function (Categories) {
|
||||
await db.sortedSetAdd('categories:name', 0, `${newName.slice(0, 200).toLowerCase()}:${cid}`);
|
||||
await db.setObjectField(`category:${cid}`, 'name', newName);
|
||||
}
|
||||
|
||||
async function updateHandle(cid, handle) {
|
||||
const existing = await Categories.getCategoryField(cid, 'handle');
|
||||
if (existing === handle) {
|
||||
return;
|
||||
}
|
||||
|
||||
const taken = await meta.slugTaken(handle);
|
||||
if (taken) {
|
||||
throw new Error('[[error:category.handle-taken]]');
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
db.setObjectField(`category:${cid}`, 'handle', handle),
|
||||
db.sortedSetRemove('categoryhandle:cid', existing),
|
||||
db.sortedSetAdd('categoryhandle:cid', cid, handle),
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
const db = require('../database');
|
||||
const user = require('../user');
|
||||
const activitypub = require('../activitypub');
|
||||
|
||||
module.exports = function (Categories) {
|
||||
Categories.watchStates = {
|
||||
@@ -20,7 +21,7 @@ module.exports = function (Categories) {
|
||||
};
|
||||
|
||||
Categories.getWatchState = async function (cids, uid) {
|
||||
if (!(parseInt(uid, 10) > 0)) {
|
||||
if (!activitypub.helpers.isUri(uid) && !(parseInt(uid, 10) > 0)) {
|
||||
return cids.map(() => Categories.watchStates.notwatching);
|
||||
}
|
||||
if (!Array.isArray(cids) || !cids.length) {
|
||||
|
||||
@@ -31,7 +31,7 @@ async function getFollow(tpl, name, req, res, next) {
|
||||
payload.title = `[[pages:${tpl}, ${username}]]`;
|
||||
|
||||
const method = name === 'following' ? 'getFollowing' : 'getFollowers';
|
||||
payload.users = await user[method](res.locals.uid, start, stop);
|
||||
payload.users = await user[method](res.locals.userData.uid, start, stop);
|
||||
|
||||
const count = name === 'following' ? followingCount : followerCount;
|
||||
const pageCount = Math.ceil(count / resultsPerPage);
|
||||
|
||||
@@ -13,6 +13,9 @@ const privileges = require('../../privileges');
|
||||
const translator = require('../../translator');
|
||||
const messaging = require('../../messaging');
|
||||
const categories = require('../../categories');
|
||||
const posts = require('../../posts');
|
||||
const activitypub = require('../../activitypub');
|
||||
const flags = require('../../flags');
|
||||
|
||||
const relative_path = nconf.get('relative_path');
|
||||
|
||||
@@ -24,7 +27,12 @@ helpers.getUserDataByUserSlug = async function (userslug, callerUID, query = {})
|
||||
return null;
|
||||
}
|
||||
|
||||
const results = await getAllData(uid, callerUID);
|
||||
const [results, canFlag, flagged, flagId] = await Promise.all([
|
||||
getAllData(uid, callerUID),
|
||||
privileges.users.canFlag(callerUID, uid),
|
||||
flags.exists('user', uid, callerUID),
|
||||
flags.getFlagIdByTarget('user', uid),
|
||||
]);
|
||||
if (!results.userData) {
|
||||
throw new Error('[[error:invalid-uid]]');
|
||||
}
|
||||
@@ -74,7 +82,8 @@ helpers.getUserDataByUserSlug = async function (userslug, callerUID, query = {})
|
||||
userData.canEdit = results.canEdit;
|
||||
userData.canBan = results.canBanUser;
|
||||
userData.canMute = results.canMuteUser;
|
||||
userData.canFlag = (await privileges.users.canFlag(callerUID, userData.uid)).flag;
|
||||
userData.canFlag = canFlag.flag;
|
||||
userData.flagId = flagged ? flagId : null;
|
||||
userData.canChangePassword = isAdmin || (isSelf && !meta.config['password:disableEdit']);
|
||||
userData.isSelf = isSelf;
|
||||
userData.isFollowing = results.isFollowing;
|
||||
@@ -181,6 +190,7 @@ async function canChat(callerUID, uid) {
|
||||
|
||||
async function getCounts(userData, callerUID) {
|
||||
const { uid } = userData;
|
||||
const isRemote = activitypub.helpers.isUri(uid);
|
||||
const cids = await categories.getCidsByPrivilege('categories:cid', callerUID, 'topics:read');
|
||||
const promises = {
|
||||
posts: db.sortedSetsCardSum(cids.map(c => `cid:${c}:uid:${uid}:pids`)),
|
||||
@@ -200,6 +210,7 @@ async function getCounts(userData, callerUID) {
|
||||
promises.blocks = user.getUserField(userData.uid, 'blocksCount');
|
||||
}
|
||||
const counts = await utils.promiseParallel(promises);
|
||||
counts.posts = isRemote ? userData.postcount : counts.posts;
|
||||
counts.categoriesWatched = counts.categoriesWatched && counts.categoriesWatched.length;
|
||||
counts.groups = userData.groups.length;
|
||||
counts.following = userData.followingCount;
|
||||
@@ -273,7 +284,12 @@ async function parseAboutMe(userData) {
|
||||
userData.aboutme = '';
|
||||
userData.aboutmeParsed = '';
|
||||
return;
|
||||
} else if (activitypub.helpers.isUri(userData.uid)) {
|
||||
userData.aboutme = posts.sanitize(userData.aboutme);
|
||||
userData.aboutmeParsed = userData.aboutme;
|
||||
return;
|
||||
}
|
||||
|
||||
userData.aboutme = validator.escape(String(userData.aboutme || ''));
|
||||
const parsed = await plugins.hooks.fire('filter:parse.aboutme', userData.aboutme);
|
||||
userData.aboutme = translator.escape(userData.aboutme);
|
||||
|
||||
@@ -177,20 +177,27 @@ async function getPostsFromUserSet(template, req, res) {
|
||||
const data = templateToData[template];
|
||||
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
|
||||
|
||||
let { uid } = res.locals;
|
||||
if (uid === -2) {
|
||||
uid = await db.getObjectField('handle:uid', req.params.userslug.toLowerCase());
|
||||
}
|
||||
|
||||
const payload = res.locals.userData;
|
||||
const { username, userslug } = payload;
|
||||
const { username, userslug } = uid === -2 ?
|
||||
await user.getUserFields(uid, ['username', 'userslug']) :
|
||||
payload;
|
||||
const settings = await user.getSettings(req.uid);
|
||||
|
||||
const itemsPerPage = data.type === 'topics' ? settings.topicsPerPage : settings.postsPerPage;
|
||||
const start = (page - 1) * itemsPerPage;
|
||||
const stop = start + itemsPerPage - 1;
|
||||
const sets = await data.getSets(req.uid, { uid: res.locals.uid, username, userslug });
|
||||
const sets = await data.getSets(req.uid, { uid, username, userslug });
|
||||
let result;
|
||||
if (plugins.hooks.hasListeners('filter:account.getPostsFromUserSet')) {
|
||||
result = await plugins.hooks.fire('filter:account.getPostsFromUserSet', {
|
||||
req: req,
|
||||
template: template,
|
||||
userData: { uid: res.locals.uid, username, userslug },
|
||||
userData: { uid, username, userslug },
|
||||
settings: settings,
|
||||
data: data,
|
||||
start: start,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
'use strict';
|
||||
|
||||
const _ = require('lodash');
|
||||
const nconf = require('nconf');
|
||||
|
||||
const db = require('../../database');
|
||||
const meta = require('../../meta');
|
||||
const user = require('../../user');
|
||||
const posts = require('../../posts');
|
||||
const categories = require('../../categories');
|
||||
@@ -41,7 +43,12 @@ profileController.get = async function (req, res, next) {
|
||||
userData.profileviews = 1;
|
||||
}
|
||||
|
||||
addMetaTags(res, userData);
|
||||
addTags(res, userData);
|
||||
|
||||
if (meta.config.activitypubEnabled) {
|
||||
// Include link header for richer parsing
|
||||
res.set('Link', `<${nconf.get('url')}/uid/${userData.uid}>; rel="alternate"; type="application/activity+json"`);
|
||||
}
|
||||
|
||||
res.render('account/profile', userData);
|
||||
};
|
||||
@@ -112,7 +119,7 @@ async function getPosts(callerUid, userData, setSuffix) {
|
||||
return postData.slice(0, count);
|
||||
}
|
||||
|
||||
function addMetaTags(res, userData) {
|
||||
function addTags(res, userData) {
|
||||
const plainAboutMe = userData.aboutme ? utils.stripHTMLTags(utils.decodeHTMLEntities(userData.aboutme)) : '';
|
||||
res.locals.metaTags = [
|
||||
{
|
||||
@@ -149,4 +156,12 @@ function addMetaTags(res, userData) {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (meta.config.activitypubEnabled) {
|
||||
res.locals.linkTags = [{
|
||||
rel: 'alternate',
|
||||
type: 'application/activity+json',
|
||||
href: `${nconf.get('url')}/uid/${userData.uid}`,
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
174
src/controllers/activitypub/actors.js
Normal file
174
src/controllers/activitypub/actors.js
Normal file
@@ -0,0 +1,174 @@
|
||||
'use strict';
|
||||
|
||||
const nconf = require('nconf');
|
||||
const winston = require('winston');
|
||||
|
||||
const db = require('../../database');
|
||||
const meta = require('../../meta');
|
||||
const privileges = require('../../privileges');
|
||||
const posts = require('../../posts');
|
||||
const topics = require('../../topics');
|
||||
const categories = require('../../categories');
|
||||
const activitypub = require('../../activitypub');
|
||||
const utils = require('../../utils');
|
||||
|
||||
const Actors = module.exports;
|
||||
|
||||
Actors.application = async function (req, res) {
|
||||
const publicKey = await activitypub.getPublicKey('uid', 0);
|
||||
const name = meta.config.title || 'NodeBB';
|
||||
|
||||
res.status(200).json({
|
||||
'@context': [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
'https://w3id.org/security/v1',
|
||||
],
|
||||
id: `${nconf.get('url')}/actor`,
|
||||
url: `${nconf.get('url')}/actor`,
|
||||
inbox: `${nconf.get('url')}/inbox`,
|
||||
outbox: `${nconf.get('url')}/outbox`,
|
||||
|
||||
type: 'Application',
|
||||
name,
|
||||
preferredUsername: nconf.get('url_parsed').hostname,
|
||||
|
||||
publicKey: {
|
||||
id: `${nconf.get('url')}/actor#key`,
|
||||
owner: `${nconf.get('url')}/actor`,
|
||||
publicKeyPem: publicKey,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
Actors.user = async function (req, res) {
|
||||
// todo: view:users priv gate
|
||||
const payload = await activitypub.mocks.actors.user(req.params.uid);
|
||||
|
||||
res.status(200).json(payload);
|
||||
};
|
||||
|
||||
Actors.userBySlug = async function (req, res) {
|
||||
const { uid } = res.locals;
|
||||
req.params.uid = uid;
|
||||
delete req.params.userslug;
|
||||
Actors.user(req, res);
|
||||
};
|
||||
|
||||
Actors.note = async function (req, res) {
|
||||
// technically a note isn't an actor, but it is here purely for organizational purposes.
|
||||
// but also, wouldn't it be wild if you could follow a note? lol.
|
||||
const allowed = utils.isNumber(req.params.pid) && await privileges.posts.can('topics:read', req.params.pid, activitypub._constants.uid);
|
||||
const post = (await posts.getPostSummaryByPids([req.params.pid], req.uid, { stripTags: false })).pop();
|
||||
if (!allowed || !post) {
|
||||
return res.sendStatus(404);
|
||||
}
|
||||
|
||||
const payload = await activitypub.mocks.note(post);
|
||||
res.status(200).json(payload);
|
||||
};
|
||||
|
||||
Actors.replies = async function (req, res) {
|
||||
const allowed = utils.isNumber(req.params.pid) && await privileges.posts.can('topics:read', req.params.pid, activitypub._constants.uid);
|
||||
const exists = await posts.exists(req.params.pid);
|
||||
if (!allowed || !exists) {
|
||||
return res.sendStatus(404);
|
||||
}
|
||||
|
||||
const page = parseInt(req.query.page, 10);
|
||||
const replies = await activitypub.helpers.generateCollection({
|
||||
set: `pid:${req.params.pid}:replies`,
|
||||
page,
|
||||
perPage: meta.config.postsPerPage,
|
||||
url: `${nconf.get('url')}/post/${req.params.pid}/replies`,
|
||||
});
|
||||
|
||||
// Convert pids to urls
|
||||
replies.orderedItems = replies.orderedItems.map(pid => (utils.isNumber(pid) ? `${nconf.get('url')}/post/${pid}` : pid));
|
||||
|
||||
const object = {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
id: `${nconf.get('url')}/post/${req.params.pid}/replies${replies.orderedItems && page ? `?page=${page}` : ''}`,
|
||||
url: `${nconf.get('url')}/post/${req.params.pid}`,
|
||||
...replies,
|
||||
};
|
||||
|
||||
res.status(200).json(object);
|
||||
};
|
||||
|
||||
Actors.topic = async function (req, res, next) {
|
||||
const allowed = await privileges.topics.can('topics:read', req.params.tid, activitypub._constants.uid);
|
||||
if (!allowed) {
|
||||
return res.sendStatus(404);
|
||||
}
|
||||
|
||||
const page = parseInt(req.query.page, 10);
|
||||
const perPage = meta.config.postsPerPage;
|
||||
const { cid, titleRaw: name, mainPid, slug } = await topics.getTopicFields(req.params.tid, ['cid', 'title', 'mainPid', 'slug']);
|
||||
try {
|
||||
let [collection, pids] = await Promise.all([
|
||||
activitypub.helpers.generateCollection({
|
||||
set: `tid:${req.params.tid}:posts`,
|
||||
method: posts.getPidsFromSet,
|
||||
page,
|
||||
perPage,
|
||||
url: `${nconf.get('url')}/topic/${req.params.tid}`,
|
||||
}),
|
||||
db.getSortedSetMembers(`tid:${req.params.tid}:posts`),
|
||||
]);
|
||||
|
||||
// Generate digest for ETag
|
||||
pids.push(mainPid);
|
||||
pids = pids.map(pid => (utils.isNumber(pid) ? `${nconf.get('url')}/post/${pid}` : pid));
|
||||
const digest = activitypub.helpers.generateDigest(new Set(pids));
|
||||
const ifNoneMatch = (req.get('If-None-Match') || '').split(',').map((tag) => {
|
||||
tag = tag.trim();
|
||||
if (tag.startsWith('"') && tag.endsWith('"')) {
|
||||
return tag.slice(1, tag.length - 1);
|
||||
}
|
||||
|
||||
return tag;
|
||||
});
|
||||
if (ifNoneMatch.includes(digest)) {
|
||||
return res.sendStatus(304);
|
||||
}
|
||||
res.set('ETag', digest);
|
||||
|
||||
// Convert pids to urls
|
||||
collection.totalItems += 1;
|
||||
if (page || collection.totalItems < meta.config.postsPerPage) {
|
||||
collection.orderedItems = collection.orderedItems || [];
|
||||
if (!page || page === 1) { // add OP to collection
|
||||
collection.orderedItems.unshift(mainPid);
|
||||
}
|
||||
collection.orderedItems = collection.orderedItems.map(pid => (utils.isNumber(pid) ? `${nconf.get('url')}/post/${pid}` : pid));
|
||||
}
|
||||
|
||||
const object = {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
id: `${nconf.get('url')}/topic/${req.params.tid}${collection.orderedItems && page ? `?page=${page}` : ''}`,
|
||||
url: `${nconf.get('url')}/topic/${slug}`,
|
||||
name,
|
||||
attributedTo: `${nconf.get('url')}/category/${cid}`,
|
||||
audience: cid !== -1 ? `${nconf.get('url')}/category/${cid}` : undefined,
|
||||
...collection,
|
||||
};
|
||||
|
||||
res.status(200).json(object);
|
||||
} catch (e) {
|
||||
winston.error(`[activitypub/actors.topic] Unable to generate topic actor: ${e.message}`);
|
||||
return next();
|
||||
}
|
||||
};
|
||||
|
||||
Actors.category = async function (req, res, next) {
|
||||
const [exists, allowed] = await Promise.all([
|
||||
categories.exists(req.params.cid),
|
||||
privileges.categories.can('find', req.params.cid, activitypub._constants.uid),
|
||||
]);
|
||||
if (!exists || !allowed) {
|
||||
return next('route');
|
||||
}
|
||||
|
||||
const payload = await activitypub.mocks.actors.category(req.params.cid);
|
||||
res.status(200).json(payload);
|
||||
};
|
||||
130
src/controllers/activitypub/index.js
Normal file
130
src/controllers/activitypub/index.js
Normal file
@@ -0,0 +1,130 @@
|
||||
'use strict';
|
||||
|
||||
const nconf = require('nconf');
|
||||
const winston = require('winston');
|
||||
|
||||
const user = require('../../user');
|
||||
const activitypub = require('../../activitypub');
|
||||
const helpers = require('../helpers');
|
||||
|
||||
const Controller = module.exports;
|
||||
|
||||
Controller.actors = require('./actors');
|
||||
Controller.topics = require('./topics');
|
||||
|
||||
Controller.getFollowing = async (req, res) => {
|
||||
const { followingCount, followingRemoteCount } = await user.getUserFields(req.params.uid, ['followingCount', 'followingRemoteCount']);
|
||||
const totalItems = parseInt(followingCount || 0, 10) + parseInt(followingRemoteCount || 0, 10);
|
||||
let orderedItems;
|
||||
let next = (totalItems && `${nconf.get('url')}/uid/${req.params.uid}/following?page=`) || null;
|
||||
|
||||
if (totalItems) {
|
||||
if (req.query.page) {
|
||||
const page = parseInt(req.query.page, 10) || 1;
|
||||
const resultsPerPage = 50;
|
||||
const start = Math.max(0, page - 1) * resultsPerPage;
|
||||
const stop = start + resultsPerPage - 1;
|
||||
|
||||
orderedItems = await user.getFollowing(req.params.uid, start, stop);
|
||||
orderedItems = orderedItems.map(({ userslug }) => `${nconf.get('url')}/user/${userslug}`);
|
||||
if (stop < totalItems - 1) {
|
||||
next = `${next}${page + 1}`;
|
||||
} else {
|
||||
next = null;
|
||||
}
|
||||
} else {
|
||||
orderedItems = [];
|
||||
next = `${next}1`;
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
type: 'OrderedCollection',
|
||||
totalItems,
|
||||
orderedItems,
|
||||
next,
|
||||
});
|
||||
};
|
||||
|
||||
Controller.getFollowers = async (req, res) => {
|
||||
const { followerCount, followerRemoteCount } = await user.getUserFields(req.params.uid, ['followerCount', 'followerRemoteCount']);
|
||||
const totalItems = parseInt(followerCount || 0, 10) + parseInt(followerRemoteCount || 0, 10);
|
||||
let orderedItems = [];
|
||||
let next = (totalItems && `${nconf.get('url')}/uid/${req.params.uid}/followers?page=`) || null;
|
||||
|
||||
if (totalItems) {
|
||||
if (req.query.page) {
|
||||
const page = parseInt(req.query.page, 10) || 1;
|
||||
const resultsPerPage = 50;
|
||||
const start = Math.max(0, page - 1) * resultsPerPage;
|
||||
const stop = start + resultsPerPage - 1;
|
||||
|
||||
orderedItems = await user.getFollowers(req.params.uid, start, stop);
|
||||
orderedItems = orderedItems.map(({ userslug }) => `${nconf.get('url')}/user/${userslug}`);
|
||||
if (stop < totalItems - 1) {
|
||||
next = `${next}${page + 1}`;
|
||||
} else {
|
||||
next = null;
|
||||
}
|
||||
} else {
|
||||
orderedItems = [];
|
||||
next = `${next}1`;
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
type: 'OrderedCollection',
|
||||
totalItems,
|
||||
orderedItems,
|
||||
next,
|
||||
});
|
||||
};
|
||||
|
||||
Controller.getOutbox = async (req, res) => {
|
||||
// stub
|
||||
res.status(200).json({
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
type: 'OrderedCollection',
|
||||
totalItems: 0,
|
||||
orderedItems: [],
|
||||
});
|
||||
};
|
||||
|
||||
Controller.getCategoryOutbox = async (req, res) => {
|
||||
// stub
|
||||
res.status(200).json({
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
type: 'OrderedCollection',
|
||||
totalItems: 0,
|
||||
orderedItems: [],
|
||||
});
|
||||
};
|
||||
|
||||
Controller.postOutbox = async (req, res) => {
|
||||
// This is a client-to-server feature so it is deliberately not implemented at this time.
|
||||
res.sendStatus(405);
|
||||
};
|
||||
|
||||
Controller.getInbox = async (req, res) => {
|
||||
// This is a client-to-server feature so it is deliberately not implemented at this time.
|
||||
res.sendStatus(405);
|
||||
};
|
||||
|
||||
Controller.postInbox = async (req, res) => {
|
||||
// Note: underlying methods are internal use only, hence no exposure via src/api
|
||||
const method = String(req.body.type).toLowerCase();
|
||||
if (!activitypub.inbox.hasOwnProperty(method)) {
|
||||
winston.warn(`[activitypub/inbox] Received Activity of type ${method} but unable to handle. Ignoring.`);
|
||||
return res.sendStatus(501);
|
||||
}
|
||||
|
||||
try {
|
||||
await activitypub.inbox[method](req);
|
||||
await activitypub.record(req.body);
|
||||
helpers.formatApiResponse(202, res);
|
||||
} catch (e) {
|
||||
helpers.formatApiResponse(500, res, e);
|
||||
}
|
||||
};
|
||||
101
src/controllers/activitypub/topics.js
Normal file
101
src/controllers/activitypub/topics.js
Normal file
@@ -0,0 +1,101 @@
|
||||
'use strict';
|
||||
|
||||
const nconf = require('nconf');
|
||||
|
||||
const db = require('../../database');
|
||||
const user = require('../../user');
|
||||
const topics = require('../../topics');
|
||||
|
||||
const pagination = require('../../pagination');
|
||||
const helpers = require('../helpers');
|
||||
|
||||
const categories = require('../../categories');
|
||||
const privileges = require('../../privileges');
|
||||
const translator = require('../../translator');
|
||||
const meta = require('../../meta');
|
||||
|
||||
const controller = module.exports;
|
||||
|
||||
const validSorts = [
|
||||
'recently_replied', 'recently_created', 'most_posts', 'most_votes', 'most_views',
|
||||
];
|
||||
|
||||
controller.list = async function (req, res) {
|
||||
if (!req.uid) {
|
||||
return helpers.redirect(res, '/recent?cid=-1', false);
|
||||
}
|
||||
|
||||
const { topicsPerPage } = await user.getSettings(req.uid);
|
||||
const page = parseInt(req.query.page, 10) || 1;
|
||||
const start = Math.max(0, (page - 1) * topicsPerPage);
|
||||
const stop = start + topicsPerPage - 1;
|
||||
|
||||
const sortToSet = {
|
||||
recently_replied: `cid:-1:tids`,
|
||||
recently_created: `cid:-1:tids:create`,
|
||||
most_posts: `cid:-1:tids:posts`,
|
||||
most_votes: `cid:-1:tids:votes`,
|
||||
most_views: `cid:-1:tids:views`,
|
||||
};
|
||||
|
||||
const [userPrivileges, tagData, userSettings, rssToken] = await Promise.all([
|
||||
privileges.categories.get('-1', req.uid),
|
||||
helpers.getSelectedTag(req.query.tag),
|
||||
user.getSettings(req.uid),
|
||||
user.auth.getFeedToken(req.uid),
|
||||
]);
|
||||
const sort = validSorts.includes(req.query.sort) ? req.query.sort : userSettings.categoryTopicSort;
|
||||
|
||||
let tids = await db.getSortedSetRevRange(sortToSet[sort], 0, 499);
|
||||
const isMembers = await db.isSortedSetMembers(`uid:${req.uid}:inbox`, tids);
|
||||
tids = tids.filter((tid, idx) => isMembers[idx]);
|
||||
const count = tids.length;
|
||||
tids = tids.slice(start, stop + 1);
|
||||
|
||||
const targetUid = await user.getUidByUserslug(req.query.author);
|
||||
|
||||
const data = await categories.getCategoryById({
|
||||
uid: req.uid,
|
||||
cid: '-1',
|
||||
start: start,
|
||||
stop: stop,
|
||||
sort: sort,
|
||||
settings: userSettings,
|
||||
query: req.query,
|
||||
tag: req.query.tag,
|
||||
targetUid: targetUid,
|
||||
});
|
||||
|
||||
data.name = '[[activitypub:world.name]]';
|
||||
delete data.children;
|
||||
|
||||
data.topicCount = count;
|
||||
data.topics = await topics.getTopicsByTids(tids, { uid: req.uid });
|
||||
topics.calculateTopicIndices(data.topics, start);
|
||||
|
||||
data.title = translator.escape(data.name);
|
||||
data.privileges = userPrivileges;
|
||||
data.selectedTag = tagData.selectedTag;
|
||||
data.selectedTags = tagData.selectedTags;
|
||||
|
||||
data.breadcrumbs = helpers.buildBreadcrumbs([{ text: `[[pages:world]]` }]);
|
||||
data['feeds:disableRSS'] = meta.config['feeds:disableRSS'] || 0;
|
||||
data['reputation:disabled'] = meta.config['reputation:disabled'];
|
||||
if (!meta.config['feeds:disableRSS']) {
|
||||
data.rssFeedUrl = `${nconf.get('url')}/category/${data.cid}.rss`;
|
||||
if (req.loggedIn) {
|
||||
data.rssFeedUrl += `?uid=${req.uid}&token=${rssToken}`;
|
||||
}
|
||||
}
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(data.topicCount / topicsPerPage));
|
||||
data.pagination = pagination.create(page, pageCount, req.query);
|
||||
helpers.addLinkTags({
|
||||
url: 'world',
|
||||
res: req.res,
|
||||
tags: data.pagination.rel,
|
||||
page: page,
|
||||
});
|
||||
|
||||
res.render('world', data);
|
||||
};
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
const _ = require('lodash');
|
||||
const nconf = require('nconf');
|
||||
const db = require('../../database');
|
||||
const user = require('../../user');
|
||||
const categories = require('../../categories');
|
||||
const analytics = require('../../analytics');
|
||||
const plugins = require('../../plugins');
|
||||
const translator = require('../../translator');
|
||||
const meta = require('../../meta');
|
||||
const activitypub = require('../../activitypub');
|
||||
const helpers = require('../helpers');
|
||||
const pagination = require('../../pagination');
|
||||
|
||||
@@ -145,3 +148,31 @@ categoriesController.getAnalytics = async function (req, res) {
|
||||
selectedCategory: selectedData.selectedCategory,
|
||||
});
|
||||
};
|
||||
|
||||
categoriesController.getFederation = async function (req, res) {
|
||||
const cid = req.params.category_id;
|
||||
let [_following, pending, followers, name, { selectedCategory }] = await Promise.all([
|
||||
db.getSortedSetMembers(`cid:${cid}:following`),
|
||||
db.getSortedSetMembers(`followRequests:cid.${cid}`),
|
||||
activitypub.notes.getCategoryFollowers(cid),
|
||||
categories.getCategoryField(cid, 'name'),
|
||||
helpers.getSelectedCategory(cid),
|
||||
]);
|
||||
|
||||
const following = [..._following, ...pending].map(entry => ({
|
||||
id: entry,
|
||||
approved: !pending.includes(entry),
|
||||
}));
|
||||
|
||||
await activitypub.actors.assert(followers);
|
||||
followers = await user.getUsersFields(followers, ['userslug', 'picture']);
|
||||
|
||||
res.render('admin/manage/category-federation', {
|
||||
cid: cid,
|
||||
enabled: meta.config.activitypubEnabled,
|
||||
name,
|
||||
selectedCategory,
|
||||
following,
|
||||
followers,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
const categories = require('../../categories');
|
||||
const privileges = require('../../privileges');
|
||||
const utils = require('../../utils');
|
||||
|
||||
const privilegesController = module.exports;
|
||||
|
||||
@@ -10,10 +11,10 @@ privilegesController.get = async function (req, res) {
|
||||
const isAdminPriv = req.params.cid === 'admin';
|
||||
|
||||
let privilegesData;
|
||||
if (cid > 0) {
|
||||
privilegesData = await privileges.categories.list(cid);
|
||||
} else if (cid === 0) {
|
||||
if (cid === 0) {
|
||||
privilegesData = await (isAdminPriv ? privileges.admin.list(req.uid) : privileges.global.list());
|
||||
} else if (utils.isNumber(cid)) {
|
||||
privilegesData = await privileges.categories.list(cid);
|
||||
}
|
||||
|
||||
const categoriesData = [{
|
||||
|
||||
@@ -9,6 +9,7 @@ const groups = require('../../groups');
|
||||
const languages = require('../../languages');
|
||||
const navigationAdmin = require('../../navigation/admin');
|
||||
const social = require('../../social');
|
||||
const activitypub = require('../../activitypub');
|
||||
const api = require('../../api');
|
||||
const pagination = require('../../pagination');
|
||||
const helpers = require('../helpers');
|
||||
@@ -123,3 +124,12 @@ settingsController.api = async (req, res) => {
|
||||
pagination: pagination.create(page, pageCount, req.query),
|
||||
});
|
||||
};
|
||||
|
||||
settingsController.activitypub = async (req, res) => {
|
||||
const instanceCount = await activitypub.instances.getCount();
|
||||
|
||||
res.render('admin/settings/activitypub', {
|
||||
title: `[[admin/menu:settings/activitypub]]`,
|
||||
instanceCount,
|
||||
});
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user