mirror of
https://github.com/NodeBB/NodeBB.git
synced 2025-12-20 07:20:27 +01:00
Compare commits
660 Commits
custom-use
...
v4.0.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -189,5 +189,10 @@
|
|||||||
"composer:allowPluginHelp": 1,
|
"composer:allowPluginHelp": 1,
|
||||||
"maxReconnectionAttempts": 5,
|
"maxReconnectionAttempts": 5,
|
||||||
"reconnectionDelay": 1500,
|
"reconnectionDelay": 1500,
|
||||||
"disableCustomUserSkins": 0
|
"disableCustomUserSkins": 0,
|
||||||
|
"activitypubEnabled": 1,
|
||||||
|
"activitypubAllowLoopback": 0,
|
||||||
|
"activitypubContentPruneDays": 30,
|
||||||
|
"activitypubUserPruneDays": 7,
|
||||||
|
"activitypubFilter": 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,14 @@
|
|||||||
"textClass": "d-lg-none",
|
"textClass": "d-lg-none",
|
||||||
"text": "[[global:header.popular]]"
|
"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",
|
"route": "/users",
|
||||||
"title": "[[global:header.users]]",
|
"title": "[[global:header.users]]",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "nodebb",
|
"name": "nodebb",
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"description": "NodeBB Forum",
|
"description": "NodeBB Forum",
|
||||||
"version": "3.8.2",
|
"version": "4.0.0-alpha",
|
||||||
"homepage": "https://www.nodebb.org",
|
"homepage": "https://www.nodebb.org",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -47,6 +47,7 @@
|
|||||||
"bootswatch": "5.3.3",
|
"bootswatch": "5.3.3",
|
||||||
"chalk": "4.1.2",
|
"chalk": "4.1.2",
|
||||||
"chart.js": "4.4.3",
|
"chart.js": "4.4.3",
|
||||||
|
"cheerio": "^1.0.0-rc.12",
|
||||||
"cli-graph": "3.2.2",
|
"cli-graph": "3.2.2",
|
||||||
"clipboard": "2.0.11",
|
"clipboard": "2.0.11",
|
||||||
"colors": "1.4.0",
|
"colors": "1.4.0",
|
||||||
@@ -99,12 +100,12 @@
|
|||||||
"nodebb-plugin-dbsearch": "6.2.5",
|
"nodebb-plugin-dbsearch": "6.2.5",
|
||||||
"nodebb-plugin-emoji": "5.1.15",
|
"nodebb-plugin-emoji": "5.1.15",
|
||||||
"nodebb-plugin-emoji-android": "4.0.0",
|
"nodebb-plugin-emoji-android": "4.0.0",
|
||||||
"nodebb-plugin-markdown": "12.2.8",
|
"nodebb-plugin-markdown": "13.0.0-pre.8",
|
||||||
"nodebb-plugin-mentions": "4.4.3",
|
"nodebb-plugin-mentions": "4.6.6",
|
||||||
"nodebb-plugin-ntfy": "1.7.4",
|
"nodebb-plugin-ntfy": "1.7.4",
|
||||||
"nodebb-plugin-spam-be-gone": "2.2.2",
|
"nodebb-plugin-spam-be-gone": "2.2.2",
|
||||||
"nodebb-rewards-essentials": "1.0.0",
|
"nodebb-rewards-essentials": "1.0.0",
|
||||||
"nodebb-theme-harmony": "1.2.59",
|
"nodebb-theme-harmony": "2.0.0-pre.27",
|
||||||
"nodebb-theme-lavender": "7.1.8",
|
"nodebb-theme-lavender": "7.1.8",
|
||||||
"nodebb-theme-peace": "2.2.5",
|
"nodebb-theme-peace": "2.2.5",
|
||||||
"nodebb-theme-persona": "13.3.21",
|
"nodebb-theme-persona": "13.3.21",
|
||||||
|
|||||||
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": "Announcers",
|
||||||
|
"announcers-x": "Announcers (%1)"
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@
|
|||||||
"privileges": "Privileges",
|
"privileges": "Privileges",
|
||||||
"back-to-categories": "Back to categories",
|
"back-to-categories": "Back to categories",
|
||||||
"name": "Category Name",
|
"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",
|
"description": "Category Description",
|
||||||
"bg-color": "Background Colour",
|
"bg-color": "Background Colour",
|
||||||
"text-color": "Text Colour",
|
"text-color": "Text Colour",
|
||||||
@@ -37,6 +39,7 @@
|
|||||||
"disable": "Disable",
|
"disable": "Disable",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"analytics": "Analytics",
|
"analytics": "Analytics",
|
||||||
|
"federation": "Federation",
|
||||||
|
|
||||||
"view-category": "View category",
|
"view-category": "View category",
|
||||||
"set-order": "Set order",
|
"set-order": "Set order",
|
||||||
@@ -76,6 +79,22 @@
|
|||||||
"analytics.topics-daily": "<strong>Figure 3</strong> – Daily topics created in this category</small>",
|
"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>",
|
"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.created": "Created",
|
||||||
"alert.create-success": "Category successfully created!",
|
"alert.create-success": "Category successfully created!",
|
||||||
"alert.none-active": "You have no active categories.",
|
"alert.none-active": "You have no active categories.",
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
"settings/tags": "Tags",
|
"settings/tags": "Tags",
|
||||||
"settings/notifications": "Notifications",
|
"settings/notifications": "Notifications",
|
||||||
"settings/api": "API Access",
|
"settings/api": "API Access",
|
||||||
|
"settings/activitypub": "Federation (ActivityPub)",
|
||||||
"settings/sounds": "Sounds",
|
"settings/sounds": "Sounds",
|
||||||
"settings/social": "Social",
|
"settings/social": "Social",
|
||||||
"settings/cookies": "Cookies",
|
"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"
|
||||||
|
}
|
||||||
@@ -264,6 +264,7 @@
|
|||||||
|
|
||||||
"topic-event-unrecognized": "Topic event '%1' unrecognized",
|
"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-child-as-parent": "Can't set child as parent category",
|
||||||
"cant-set-self-as-parent": "Can't set self as parent category",
|
"cant-set-self-as-parent": "Can't set self as parent category",
|
||||||
|
|
||||||
@@ -277,5 +278,12 @@
|
|||||||
"api.500": "An unexpected error was encountered while attempting to service your request.",
|
"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.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.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-offensive": "Offensive",
|
||||||
"modal-reason-other": "Other (specify below)",
|
"modal-reason-other": "Other (specify below)",
|
||||||
"modal-reason-custom": "Reason for reporting this content...",
|
"modal-reason-custom": "Reason for reporting this content...",
|
||||||
|
"modal-notify-remote": "Forward this report to %1",
|
||||||
"modal-submit": "Submit Report",
|
"modal-submit": "Submit Report",
|
||||||
"modal-submit-success": "Content has been flagged for moderation.",
|
"modal-submit-success": "Content has been flagged for moderation.",
|
||||||
|
|
||||||
|
"modal-confirm-rescind": "Rescind Report?",
|
||||||
|
|
||||||
"bulk-actions": "Bulk Actions",
|
"bulk-actions": "Bulk Actions",
|
||||||
"bulk-resolve": "Resolve Flag(s)",
|
"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",
|
"bulk-success": "%1 flags updated",
|
||||||
"flagged-timeago": "Flagged <span class=\"timeago\" title=\"%1\"></span>",
|
"flagged-timeago": "Flagged <span class=\"timeago\" title=\"%1\"></span>",
|
||||||
"auto-flagged": "[Auto Flagged] Received %1 downvotes."
|
"auto-flagged": "[Auto Flagged] Received %1 downvotes."
|
||||||
|
|||||||
@@ -56,6 +56,7 @@
|
|||||||
"header.navigation": "Navigation",
|
"header.navigation": "Navigation",
|
||||||
"header.manage": "Manage",
|
"header.manage": "Manage",
|
||||||
"header.drafts": "Drafts",
|
"header.drafts": "Drafts",
|
||||||
|
"header.world": "World",
|
||||||
|
|
||||||
"notifications.loading": "Loading Notifications",
|
"notifications.loading": "Loading Notifications",
|
||||||
"chats.loading": "Loading Chats",
|
"chats.loading": "Loading Chats",
|
||||||
|
|||||||
@@ -106,5 +106,10 @@
|
|||||||
"notificationType-post-queue": "When a new post is queued",
|
"notificationType-post-queue": "When a new post is queued",
|
||||||
"notificationType-new-post-flag": "When a post is flagged",
|
"notificationType-new-post-flag": "When a post is flagged",
|
||||||
"notificationType-new-user-flag": "When a user 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",
|
"flags": "Flags",
|
||||||
"flag-details": "Flag %1 Details",
|
"flag-details": "Flag %1 Details",
|
||||||
|
|
||||||
|
"world": "World",
|
||||||
|
|
||||||
"account/edit": "Editing \"%1\"",
|
"account/edit": "Editing \"%1\"",
|
||||||
"account/edit/password": "Editing password of \"%1\"",
|
"account/edit/password": "Editing password of \"%1\"",
|
||||||
"account/edit/username": "Editing username of \"%1\"",
|
"account/edit/username": "Editing username of \"%1\"",
|
||||||
|
|||||||
@@ -152,6 +152,7 @@
|
|||||||
"bookmarks.has-no-bookmarks": "You haven't bookmarked any posts yet.",
|
"bookmarks.has-no-bookmarks": "You haven't bookmarked any posts yet.",
|
||||||
|
|
||||||
"copy-permalink": "Copy Permalink",
|
"copy-permalink": "Copy Permalink",
|
||||||
|
"go-to-original": "View Original Post",
|
||||||
|
|
||||||
"loading-more-posts": "Loading More Posts",
|
"loading-more-posts": "Loading More Posts",
|
||||||
"move-topic": "Move Topic",
|
"move-topic": "Move Topic",
|
||||||
|
|||||||
@@ -60,6 +60,7 @@
|
|||||||
"chat-with": "Continue chat with %1",
|
"chat-with": "Continue chat with %1",
|
||||||
"new-chat-with": "Start new chat with %1",
|
"new-chat-with": "Start new chat with %1",
|
||||||
"flag-profile": "Flag Profile",
|
"flag-profile": "Flag Profile",
|
||||||
|
"profile-flagged": "Already flagged",
|
||||||
"follow": "Follow",
|
"follow": "Follow",
|
||||||
"unfollow": "Unfollow",
|
"unfollow": "Unfollow",
|
||||||
"more": "More",
|
"more": "More",
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ CategoryObject:
|
|||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
description: The category's name/title
|
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:
|
description:
|
||||||
type: string
|
type: string
|
||||||
description: A variable-length description of the category (usually displayed underneath the category name)
|
description: A variable-length description of the category (usually displayed underneath the category name)
|
||||||
|
|||||||
@@ -7,6 +7,15 @@ PostObject:
|
|||||||
tid:
|
tid:
|
||||||
type: number
|
type: number
|
||||||
description: A topic identifier
|
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:
|
content:
|
||||||
type: string
|
type: string
|
||||||
uid:
|
uid:
|
||||||
|
|||||||
@@ -449,6 +449,9 @@ UserObjectFull:
|
|||||||
type: boolean
|
type: boolean
|
||||||
canFlag:
|
canFlag:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
flagId:
|
||||||
|
type: number
|
||||||
|
nullable: true
|
||||||
canChangePassword:
|
canChangePassword:
|
||||||
type: boolean
|
type: boolean
|
||||||
isSelf:
|
isSelf:
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ paths:
|
|||||||
$ref: 'read/admin/manage/categories/category_id.yaml'
|
$ref: 'read/admin/manage/categories/category_id.yaml'
|
||||||
"/api/admin/manage/categories/{category_id}/analytics":
|
"/api/admin/manage/categories/{category_id}/analytics":
|
||||||
$ref: 'read/admin/manage/categories/category_id/analytics.yaml'
|
$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}":
|
"/api/admin/manage/privileges/{cid}":
|
||||||
$ref: 'read/admin/manage/privileges/cid.yaml'
|
$ref: 'read/admin/manage/privileges/cid.yaml'
|
||||||
/api/admin/manage/tags:
|
/api/admin/manage/tags:
|
||||||
@@ -326,5 +328,7 @@ paths:
|
|||||||
$ref: 'read/groups/slug.yaml'
|
$ref: 'read/groups/slug.yaml'
|
||||||
"/api/groups/{slug}/members":
|
"/api/groups/{slug}/members":
|
||||||
$ref: 'read/groups/slug/members.yaml'
|
$ref: 'read/groups/slug/members.yaml'
|
||||||
|
"/api/world":
|
||||||
|
$ref: 'read/world.yaml'
|
||||||
/api/outgoing:
|
/api/outgoing:
|
||||||
$ref: 'read/outgoing.yaml'
|
$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
|
||||||
@@ -184,6 +184,8 @@ get:
|
|||||||
type: boolean
|
type: boolean
|
||||||
downvoted:
|
downvoted:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
attachments:
|
||||||
|
type: array
|
||||||
replies:
|
replies:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
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'
|
$ref: 'write/categories/cid/privileges/privilege.yaml'
|
||||||
/categories/{cid}/moderator/{uid}:
|
/categories/{cid}/moderator/{uid}:
|
||||||
$ref: 'write/categories/cid/moderator/uid.yaml'
|
$ref: 'write/categories/cid/moderator/uid.yaml'
|
||||||
|
/categories/{cid}/follow:
|
||||||
|
$ref: 'write/categories/cid/follow.yaml'
|
||||||
/topics/:
|
/topics/:
|
||||||
$ref: 'write/topics.yaml'
|
$ref: 'write/topics.yaml'
|
||||||
/topics/{tid}:
|
/topics/{tid}:
|
||||||
@@ -184,6 +186,10 @@ paths:
|
|||||||
$ref: 'write/posts/pid/voters.yaml'
|
$ref: 'write/posts/pid/voters.yaml'
|
||||||
/posts/{pid}/upvoters:
|
/posts/{pid}/upvoters:
|
||||||
$ref: 'write/posts/pid/upvoters.yaml'
|
$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:
|
/posts/{pid}/bookmark:
|
||||||
$ref: 'write/posts/pid/bookmark.yaml'
|
$ref: 'write/posts/pid/bookmark.yaml'
|
||||||
/posts/{pid}/diffs:
|
/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,11 @@ get:
|
|||||||
type: boolean
|
type: boolean
|
||||||
downvoted:
|
downvoted:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
attachments:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
description: A sha256 hash of the attachment (tied to the corresponding entry in the database)
|
||||||
put:
|
put:
|
||||||
tags:
|
tags:
|
||||||
- posts
|
- 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
|
||||||
|
|
||||||
|
|
||||||
@@ -33,14 +33,6 @@
|
|||||||
#available {
|
#available {
|
||||||
.drag-item {
|
.drag-item {
|
||||||
cursor: move;
|
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 };
|
ajaxify.data.privileges = { ...ajaxify.data.privileges, ...privileges };
|
||||||
const tpl = parseInt(cid, 10) ? 'admin/partials/privileges/category' : 'admin/partials/privileges/global';
|
const tpl = parseInt(cid, 10) ? 'admin/partials/privileges/category' : 'admin/partials/privileges/global';
|
||||||
const isAdminPriv = ajaxify.currentPage.endsWith('admin/manage/privileges/admin');
|
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
|
// Get currently selected filters
|
||||||
const btnIndices = $('.privilege-filters button.btn-warning').map((idx, el) => $(el).index()).get();
|
const btnIndices = $('.privilege-filters button.btn-warning').map((idx, el) => $(el).index()).get();
|
||||||
$('.privilege-table-container').html(html);
|
$('.privilege-table-container').html(html);
|
||||||
@@ -228,7 +228,7 @@ define('admin/manage/privileges', [
|
|||||||
applyPrivileges(bannedUsersPrivs, getBannedUsersInputSelector);
|
applyPrivileges(bannedUsersPrivs, getBannedUsersInputSelector);
|
||||||
|
|
||||||
// For rest that inherits from registered-users
|
// 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');
|
const registeredUsersPrivs = getPrivilegesFromRow('registered-users');
|
||||||
applyPrivileges(registeredUsersPrivs, getRegisteredUsersInputSelector);
|
applyPrivileges(registeredUsersPrivs, getRegisteredUsersInputSelector);
|
||||||
};
|
};
|
||||||
@@ -240,7 +240,7 @@ define('admin/manage/privileges', [
|
|||||||
inputSelectorFn = () => `.privilege-table tr[data-banned] td[data-privilege]:nth-child(${columnNo}) input`;
|
inputSelectorFn = () => `.privilege-table tr[data-banned] td[data-privilege]:nth-child(${columnNo}) input`;
|
||||||
break;
|
break;
|
||||||
default:
|
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);
|
const sourceChecked = getPrivilegeFromColumn(sourceGroupName, columnNo);
|
||||||
|
|||||||
@@ -510,7 +510,7 @@ define('admin/manage/users', [
|
|||||||
if (confirm) {
|
if (confirm) {
|
||||||
Promise.all(
|
Promise.all(
|
||||||
uids.map(
|
uids.map(
|
||||||
uid => api.del(`/users/${uid}${path}`, {}).then(() => {
|
uid => api.del(`/users/${encodeURIComponent(uid)}${path}`, {}).then(() => {
|
||||||
if (path !== '/content') {
|
if (path !== '/content') {
|
||||||
removeRow(uid);
|
removeRow(uid);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ define('forum/account/header', [
|
|||||||
components.get('account/delete-content').on('click', () => AccountsDelete.content(ajaxify.data.theirid));
|
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/delete-all').on('click', () => AccountsDelete.purge(ajaxify.data.theirid));
|
||||||
components.get('account/flag').on('click', flagAccount);
|
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/block').on('click', () => toggleBlockAccount('block'));
|
||||||
components.get('account/unblock').on('click', () => toggleBlockAccount('unblock'));
|
components.get('account/unblock').on('click', () => toggleBlockAccount('unblock'));
|
||||||
};
|
};
|
||||||
@@ -108,7 +109,8 @@ define('forum/account/header', [
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleFollow(type) {
|
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) {
|
if (err) {
|
||||||
return alerts.error(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) {
|
function toggleBlockAccount(action) {
|
||||||
socket.emit('user.toggleBlock', {
|
socket.emit('user.toggleBlock', {
|
||||||
blockeeUid: ajaxify.data.uid,
|
blockeeUid: ajaxify.data.uid,
|
||||||
|
|||||||
@@ -213,13 +213,34 @@ export function handleBulkActions() {
|
|||||||
const subselector = e.target.closest('[data-action]');
|
const subselector = e.target.closest('[data-action]');
|
||||||
if (subselector) {
|
if (subselector) {
|
||||||
const action = subselector.getAttribute('data-action');
|
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 flagIds = getSelected();
|
||||||
const promises = flagIds.map((flagId) => {
|
const promises = flagIds.map(async (flagId) => {
|
||||||
const data = {};
|
const data = {};
|
||||||
if (action === 'bulk-assign') {
|
switch (action) {
|
||||||
|
case 'bulk-assign': {
|
||||||
data.assignee = app.user.uid;
|
data.assignee = app.user.uid;
|
||||||
} else if (action === 'bulk-mark-resolved') {
|
break;
|
||||||
|
}
|
||||||
|
case 'bulk-mark-resolved': {
|
||||||
data.state = 'resolved';
|
data.state = 'resolved';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'bulk-purge': {
|
||||||
|
await confirmed;
|
||||||
|
return api.del(`/flags/${flagId}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return api.put(`/flags/${flagId}`, data);
|
return api.put(`/flags/${flagId}`, data);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -302,7 +302,7 @@ define('forum/topic', [
|
|||||||
destroyed = false;
|
destroyed = false;
|
||||||
|
|
||||||
async function renderPost(pid) {
|
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();
|
$('#post-tooltip').remove();
|
||||||
if (postData && ajaxify.data.template.topic) {
|
if (postData && ajaxify.data.template.topic) {
|
||||||
postCache[pid] = postData;
|
postCache[pid] = postData;
|
||||||
@@ -329,11 +329,11 @@ define('forum/topic', [
|
|||||||
const pathname = location.pathname;
|
const pathname = location.pathname;
|
||||||
const validHref = href && href !== '#' && window.location.hostname === location.hostname;
|
const validHref = href && href !== '#' && window.location.hostname === location.hostname;
|
||||||
$('#post-tooltip').remove();
|
$('#post-tooltip').remove();
|
||||||
const postMatch = validHref && pathname && pathname.match(/\/post\/([\d]+)/);
|
const postMatch = validHref && pathname && pathname.match(/\/post\/([\d]+|(?:[\w_.~!$&'()*+,;=:@-]|%[\dA-F]{2})+)/);
|
||||||
const topicMatch = validHref && pathname && pathname.match(/\/topic\/([\d]+)/);
|
const topicMatch = validHref && pathname && pathname.match(/\/topic\/([\da-z-]+)/);
|
||||||
if (postMatch) {
|
if (postMatch) {
|
||||||
const pid = postMatch[1];
|
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
|
return; // dont render self post
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,10 +35,10 @@ define('forum/topic/delete-posts', [
|
|||||||
showPostsSelected();
|
showPostsSelected();
|
||||||
|
|
||||||
deleteBtn.on('click', function () {
|
deleteBtn.on('click', function () {
|
||||||
deletePosts(deleteBtn, pid => `/posts/${pid}/state`);
|
deletePosts(deleteBtn, pid => `/posts/${encodeURIComponent(pid)}/state`);
|
||||||
});
|
});
|
||||||
purgeBtn.on('click', function () {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
api.get(`/posts/${pid}/diffs`, {}).then((data) => {
|
api.get(`/posts/${encodeURIComponent(pid)}/diffs`, {}).then((data) => {
|
||||||
parsePostHistory(data).then(($html) => {
|
parsePostHistory(data).then(($html) => {
|
||||||
const $modal = bootbox.dialog({
|
const $modal = bootbox.dialog({
|
||||||
title: '[[topic:diffs.title]]',
|
title: '[[topic:diffs.title]]',
|
||||||
@@ -57,7 +57,7 @@ define('forum/topic/diffs', ['api', 'bootbox', 'alerts', 'forum/topic/images'],
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
api.get(`/posts/${pid}/diffs/${since}`, {}).then((data) => {
|
api.get(`/posts/${encodeURIComponent(pid)}/diffs/${since}`, {}).then((data) => {
|
||||||
data.deleted = !!parseInt(data.deleted, 10);
|
data.deleted = !!parseInt(data.deleted, 10);
|
||||||
|
|
||||||
app.parseAndTranslate('partials/posts_list', 'posts', {
|
app.parseAndTranslate('partials/posts_list', 'posts', {
|
||||||
@@ -74,14 +74,14 @@ define('forum/topic/diffs', ['api', 'bootbox', 'alerts', 'forum/topic/images'],
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
api.put(`/posts/${pid}/diffs/${since}`, {}).then(() => {
|
api.put(`/posts/${encodeURIComponent(pid)}/diffs/${since}`, {}).then(() => {
|
||||||
$modal.modal('hide');
|
$modal.modal('hide');
|
||||||
alerts.success('[[topic:diffs.post-restored]]');
|
alerts.success('[[topic:diffs.post-restored]]');
|
||||||
}).catch(alerts.error);
|
}).catch(alerts.error);
|
||||||
};
|
};
|
||||||
|
|
||||||
Diffs.delete = function (pid, timestamp, $selectEl, $numberOfDiffCon) {
|
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) => {
|
parsePostHistory(data, 'diffs').then(($html) => {
|
||||||
$selectEl.empty().append($html);
|
$selectEl.empty().append($html);
|
||||||
$selectEl.trigger('change');
|
$selectEl.trigger('change');
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ define('forum/topic/events', [
|
|||||||
|
|
||||||
function updatePostVotesAndUserReputation(data) {
|
function updatePostVotesAndUserReputation(data) {
|
||||||
const votes = $('[data-pid="' + data.post.pid + '"] [component="post/vote-count"]').filter(function (index, el) {
|
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 + '"]');
|
const reputationElements = $('.reputation[data-uid="' + data.post.uid + '"]');
|
||||||
votes.html(data.post.votes).attr('data-votes', data.post.votes);
|
votes.html(data.post.votes).attr('data-votes', data.post.votes);
|
||||||
@@ -101,15 +101,15 @@ define('forum/topic/events', [
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onPostEdited(data) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const editedPostEl = components.get('post/content', data.post.pid).filter(function (index, el) {
|
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 postContainer = $(`[data-pid="${data.post.pid}"]`);
|
||||||
const editorEl = postContainer.find('[component="post/editor"]').filter(function (index, el) {
|
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 topicTitle = components.get('topic/title');
|
||||||
const navbarTitle = components.get('navbar/title').find('span');
|
const navbarTitle = components.get('navbar/title').find('span');
|
||||||
@@ -225,10 +225,10 @@ define('forum/topic/events', [
|
|||||||
function togglePostVote(data) {
|
function togglePostVote(data) {
|
||||||
const post = $('[data-pid="' + data.post.pid + '"]');
|
const post = $('[data-pid="' + data.post.pid + '"]');
|
||||||
post.find('[component="post/upvote"]').filter(function (index, el) {
|
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);
|
}).toggleClass('upvoted', data.upvote);
|
||||||
post.find('[component="post/downvote"]').filter(function (index, el) {
|
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);
|
}).toggleClass('downvoted', data.downvote);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ define('forum/topic/move-post', [
|
|||||||
return;
|
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,
|
tid: data.tid,
|
||||||
}))).then(() => {
|
}))).then(() => {
|
||||||
data.pids.forEach(function (pid) {
|
data.pids.forEach(function (pid) {
|
||||||
|
|||||||
@@ -141,6 +141,10 @@ define('forum/topic/postTools', [
|
|||||||
votes.showVotes(getData($(this), 'data-pid'));
|
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 () {
|
postContainer.on('click', '[component="post/flag"]', function () {
|
||||||
const pid = getData($(this), 'data-pid');
|
const pid = getData($(this), 'data-pid');
|
||||||
require(['flags'], function (flags) {
|
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 () {
|
postContainer.on('click', '[component="post/flagUser"]', function () {
|
||||||
const uid = getData($(this), 'data-uid');
|
const uid = getData($(this), 'data-uid');
|
||||||
require(['flags'], function (flags) {
|
require(['flags'], function (flags) {
|
||||||
@@ -322,7 +338,7 @@ define('forum/topic/postTools', [
|
|||||||
return quote(selectedNode.text);
|
return quote(selectedNode.text);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { content } = await api.get(`/posts/${toPid}/raw`);
|
const { content } = await api.get(`/posts/${encodeURIComponent(toPid)}/raw`);
|
||||||
quote(content);
|
quote(content);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -352,7 +368,7 @@ define('forum/topic/postTools', [
|
|||||||
function bookmarkPost(button, pid) {
|
function bookmarkPost(button, pid) {
|
||||||
const method = button.attr('data-bookmarked') === 'false' ? 'put' : 'del';
|
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) {
|
if (err) {
|
||||||
return alerts.error(err);
|
return alerts.error(err);
|
||||||
}
|
}
|
||||||
@@ -421,7 +437,7 @@ define('forum/topic/postTools', [
|
|||||||
|
|
||||||
const route = action === 'purge' ? '' : '/state';
|
const route = action === 'purge' ? '' : '/state';
|
||||||
const method = action === 'restore' ? 'put' : 'del';
|
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 after = parseInt(afterEl.attr('data-index'), 10) || 0;
|
||||||
|
|
||||||
const tid = ajaxify.data.tid;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ define('forum/topic/replies', ['forum/topic/posts', 'hooks', 'alerts', 'api'], f
|
|||||||
.removeClass('fa-chevron-down')
|
.removeClass('fa-chevron-down')
|
||||||
.addClass('fa-spin fa-spinner');
|
.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;
|
const postData = replies;
|
||||||
open.removeAttr('loading')
|
open.removeAttr('loading')
|
||||||
.attr('loaded', '1')
|
.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('mouseenter', '[data-pid] [component="post/vote-count"]', loadDataAndCreateTooltip);
|
||||||
components.get('topic').on('mouseleave', '[data-pid] [component="post/vote-count"]', destroyTooltip);
|
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 canSeeVotes() {
|
function canSeeVotes() {
|
||||||
@@ -43,8 +46,11 @@ define('forum/topic/votes', [
|
|||||||
tooltip.dispose();
|
tooltip.dispose();
|
||||||
$this.attr('title', '');
|
$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) {
|
if (err) {
|
||||||
return alerts.error(err);
|
return alerts.error(err);
|
||||||
}
|
}
|
||||||
@@ -86,7 +92,7 @@ define('forum/topic/votes', [
|
|||||||
|
|
||||||
const method = currentState ? 'del' : 'put';
|
const method = currentState ? 'del' : 'put';
|
||||||
const pid = post.attr('data-pid');
|
const pid = post.attr('data-pid');
|
||||||
api[method](`/posts/${pid}/vote`, {
|
api[method](`/posts/${encodeURIComponent(pid)}/vote`, {
|
||||||
delta: delta,
|
delta: delta,
|
||||||
}, function (err) {
|
}, function (err) {
|
||||||
if (err) {
|
if (err) {
|
||||||
@@ -110,7 +116,7 @@ define('forum/topic/votes', [
|
|||||||
if (!canSeeVotes()) {
|
if (!canSeeVotes()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
api.get(`/posts/${pid}/voters`, {}, function (err, data) {
|
api.get(`/posts/${encodeURIComponent(pid)}/voters`, {}, function (err, data) {
|
||||||
if (err) {
|
if (err) {
|
||||||
return alerts.error(err);
|
return alerts.error(err);
|
||||||
}
|
}
|
||||||
@@ -132,6 +138,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;
|
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 res = await fetch(url, options);
|
||||||
const { headers } = res;
|
const { headers } = res;
|
||||||
|
|
||||||
|
if (headers.get('x-redirect')) {
|
||||||
|
return xhr({ url: headers.get('x-redirect'), ...options });
|
||||||
|
}
|
||||||
|
|
||||||
const contentType = headers.get('content-type');
|
const contentType = headers.get('content-type');
|
||||||
const isJSON = contentType && contentType.startsWith('application/json');
|
const isJSON = contentType && contentType.startsWith('application/json');
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ define('flags', ['hooks', 'components', 'api', 'alerts'], function (hooks, compo
|
|||||||
let flagReason;
|
let flagReason;
|
||||||
|
|
||||||
Flag.showFlagModal = function (data) {
|
Flag.showFlagModal = function (data) {
|
||||||
|
data.remote = URL.canParse(data.id) ? new URL(data.id).hostname : false;
|
||||||
|
|
||||||
app.parseAndTranslate('modals/flag', data, function (html) {
|
app.parseAndTranslate('modals/flag', data, function (html) {
|
||||||
flagModal = html;
|
flagModal = html;
|
||||||
flagModal.on('hidden.bs.modal', function () {
|
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') {
|
if (selected.attr('id') === 'flag-reason-other') {
|
||||||
reason = flagReason.val();
|
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 () {
|
flagModal.on('click', '#flag-reason-other', function () {
|
||||||
flagReason.focus();
|
flagReason.focus();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
flagModal.modal('show');
|
flagModal.modal('show');
|
||||||
hooks.fire('action:flag.showModal', {
|
hooks.fire('action:flag.showModal', {
|
||||||
modalEl: flagModal,
|
modalEl: flagModal,
|
||||||
type: data.type,
|
type: data.type,
|
||||||
id: data.id,
|
id: data.id,
|
||||||
|
remote: data.remote,
|
||||||
});
|
});
|
||||||
|
|
||||||
flagModal.find('#flag-reason-custom').on('keyup blur change', checkFlagButtonEnable);
|
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);
|
}).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) {
|
if (!type || !id || !reason) {
|
||||||
return;
|
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) {
|
api.post('/flags', data, function (err, flagId) {
|
||||||
if (err) {
|
if (err) {
|
||||||
return alerts.error(err);
|
return alerts.error(err);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ module.exports = function (utils, Benchpress, relative_path) {
|
|||||||
generateWroteReplied,
|
generateWroteReplied,
|
||||||
generateRepliedTo,
|
generateRepliedTo,
|
||||||
generateWrote,
|
generateWrote,
|
||||||
|
encodeURIComponent: _encodeURIComponent,
|
||||||
isoTimeToLocaleString,
|
isoTimeToLocaleString,
|
||||||
shouldHideReplyContainer,
|
shouldHideReplyContainer,
|
||||||
humanReadableNumber,
|
humanReadableNumber,
|
||||||
@@ -176,7 +177,7 @@ module.exports = function (utils, Benchpress, relative_path) {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function spawnPrivilegeStates(member, privileges, types) {
|
function spawnPrivilegeStates(cid, member, privileges, types) {
|
||||||
const states = [];
|
const states = [];
|
||||||
for (const priv in privileges) {
|
for (const priv in privileges) {
|
||||||
if (privileges.hasOwnProperty(priv)) {
|
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 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 spidersEnabled = ['groups:find', 'groups:read', 'groups:topics:read', 'groups:view:users', 'groups:view:tags', 'groups:view:groups'];
|
||||||
const globalModDisabled = ['groups:moderate'];
|
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 =
|
const disabled =
|
||||||
(member === 'guests' && (guestDisabled.includes(priv.name) || priv.name.startsWith('groups:admin:'))) ||
|
(member === 'guests' && (guestDisabled.includes(priv.name) || priv.name.startsWith('groups:admin:'))) ||
|
||||||
(member === 'spiders' && !spidersEnabled.includes(priv.name)) ||
|
(member === 'spiders' && !spidersEnabled.includes(priv.name)) ||
|
||||||
|
(member === 'fediverse' && !fediverseEnabled.includes(priv.name)) ||
|
||||||
(member === 'Global Moderators' && globalModDisabled.includes(priv.name));
|
(member === 'Global Moderators' && globalModDisabled.includes(priv.name));
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<td data-privilege="${priv.name}" data-value="${priv.state}" data-type="${priv.type}">
|
<td data-privilege="${priv.name}" data-value="${priv.state}" data-type="${priv.type}">
|
||||||
<div class="form-check text-center">
|
<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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
`;
|
`;
|
||||||
@@ -334,13 +340,17 @@ module.exports = function (utils, Benchpress, relative_path) {
|
|||||||
post.parent.displayname : '[[global:guest]]';
|
post.parent.displayname : '[[global:guest]]';
|
||||||
const isBeforeCutoff = post.timestamp < (Date.now() - (timeagoCutoff * oneDayInMs));
|
const isBeforeCutoff = post.timestamp < (Date.now() - (timeagoCutoff * oneDayInMs));
|
||||||
const langSuffix = isBeforeCutoff ? 'on' : 'ago';
|
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) {
|
function generateWrote(post, timeagoCutoff) {
|
||||||
const isBeforeCutoff = post.timestamp < (Date.now() - (timeagoCutoff * oneDayInMs));
|
const isBeforeCutoff = post.timestamp < (Date.now() - (timeagoCutoff * oneDayInMs));
|
||||||
const langSuffix = isBeforeCutoff ? 'on' : 'ago';
|
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') {
|
function isoTimeToLocaleString(isoTime, locale = 'en-GB') {
|
||||||
|
|||||||
@@ -10,8 +10,8 @@
|
|||||||
window.slugify = factory(XRegExp);
|
window.slugify = factory(XRegExp);
|
||||||
}
|
}
|
||||||
}(function (XRegExp) {
|
}(function (XRegExp) {
|
||||||
const invalidUnicodeChars = XRegExp('[^\\p{L}\\s\\d\\-_]', 'g');
|
const invalidUnicodeChars = XRegExp('[^\\p{L}\\s\\d\\-_@.]', 'g');
|
||||||
const invalidLatinChars = /[^\w\s\d\-_]/g;
|
const invalidLatinChars = /[^\w\s\d\-_@.]/g;
|
||||||
const trimRegex = /^\s+|\s+$/g;
|
const trimRegex = /^\s+|\s+$/g;
|
||||||
const collapseWhitespace = /\s+/g;
|
const collapseWhitespace = /\s+/g;
|
||||||
const collapseDash = /-+/g;
|
const collapseDash = /-+/g;
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ define('tagFilter', ['hooks', 'alerts', 'bootstrap'], function (hooks, alerts, b
|
|||||||
|
|
||||||
function loadList(query, callback) {
|
function loadList(query, callback) {
|
||||||
let cids = null;
|
let cids = null;
|
||||||
if (ajaxify.data.template.category) {
|
if (ajaxify.data.template.category || ajaxify.data.template.world) {
|
||||||
cids = [ajaxify.data.cid];
|
cids = [ajaxify.data.cid];
|
||||||
// selectedCids is avaiable on /recent, /unread, /popular etc.
|
// selectedCids is avaiable on /recent, /unread, /popular etc.
|
||||||
} else if (Array.isArray(ajaxify.data.selectedCids) && ajaxify.data.selectedCids.length) {
|
} 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.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`, {
|
Thumbs.delete = (id, path) => api.del(`/topics/${id}/thumbs`, {
|
||||||
path: path,
|
path: path,
|
||||||
|
|||||||
306
src/activitypub/actors.js
Normal file
306
src/activitypub/actors.js
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
'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 = {}) => {
|
||||||
|
// 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
|
||||||
|
ids = (await Promise.all(ids.map(async (id) => {
|
||||||
|
const originalId = id;
|
||||||
|
if (activitypub.helpers.isWebfinger(id)) {
|
||||||
|
const host = id.split('@')[1];
|
||||||
|
if (host === nconf.get('url_parsed').host) { // do not assert loopback ids
|
||||||
|
return 'loopback';
|
||||||
|
}
|
||||||
|
|
||||||
|
({ actorUri: id } = await activitypub.helpers.query(id));
|
||||||
|
}
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// 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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post count
|
||||||
|
try {
|
||||||
|
const outbox = actor.outbox ? await activitypub.get('uid', 0, actor.outbox) : { totalItems: 0 };
|
||||||
|
actor.postcount = outbox.totalItems;
|
||||||
|
} catch (e) {
|
||||||
|
// no action required
|
||||||
|
// winston.verbose(`[activitypub/actor.assert] Unable to retrieve post 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)).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 (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.getLocalFollowersCount = async (id) => {
|
||||||
|
if (!activitypub.helpers.isUri(id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await db.sortedSetCard(`followersRemote:${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 counts = 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 count = counts[idx];
|
||||||
|
if (count < 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.`);
|
||||||
|
};
|
||||||
358
src/activitypub/helpers.js
Normal file
358
src/activitypub/helpers.js
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { generateKeyPairSync } = require('crypto');
|
||||||
|
const nconf = require('nconf');
|
||||||
|
const validator = require('validator');
|
||||||
|
const cheerio = require('cheerio');
|
||||||
|
|
||||||
|
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 activitypub = require('.');
|
||||||
|
|
||||||
|
const webfingerRegex = /^(@|acct:)?[\w-]+@.+$/;
|
||||||
|
const webfingerCache = ttl({
|
||||||
|
max: 5000,
|
||||||
|
ttl: 1000 * 60 * 60 * 24, // 24 hours
|
||||||
|
});
|
||||||
|
|
||||||
|
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
|
||||||
|
const [username, hostname] = isUri ? [uri.pathname || uri.href, uri.host] : id.split('@');
|
||||||
|
if (!username || !hostname) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://bb.devnull.land/cid/2#activity/follow/activitypub@community.nodebb.org│
|
||||||
|
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();
|
||||||
|
|
||||||
|
// 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]]), []));
|
||||||
516
src/activitypub/inbox.js
Normal file
516
src/activitypub/inbox.js
Normal file
@@ -0,0 +1,516 @@
|
|||||||
|
'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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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: `${id}#activity/announce/${Date.now()}`,
|
||||||
|
type: 'Announce',
|
||||||
|
to: [`${nconf.get('url')}/category/${cid}/followers`],
|
||||||
|
cc: [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 '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 numFollowers = await activitypub.actors.getLocalFollowersCount(actor);
|
||||||
|
if (!numFollowers) {
|
||||||
|
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)) is received
|
||||||
|
if (object.type === 'Create' && object.object.type === 'Note') {
|
||||||
|
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 = await user.exists(id);
|
||||||
|
if (!exists) {
|
||||||
|
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);
|
||||||
|
|
||||||
|
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 now = Date.now();
|
||||||
|
await Promise.all([
|
||||||
|
db.sortedSetRemove(`followRequests:uid.${id}`, actor),
|
||||||
|
db.sortedSetAdd(`followingRemote:${id}`, now, actor),
|
||||||
|
db.sortedSetAdd(`followersRemote:${actor}`, now, 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 now = Date.now();
|
||||||
|
await Promise.all([
|
||||||
|
db.sortedSetRemove(`followRequests:cid.${id}`, actor),
|
||||||
|
db.sortedSetAdd(`cid:${id}:following`, now, actor),
|
||||||
|
db.sortedSetAdd(`followersRemote:${actor}`, now, `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 db.sortedSetRemove(`followersRemote:${id}`, actor);
|
||||||
|
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);
|
||||||
|
};
|
||||||
368
src/activitypub/index.js
Normal file
368
src/activitypub/index.js
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
'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',
|
||||||
|
],
|
||||||
|
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.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 Promise.all(ids.map(async (id) => {
|
||||||
|
const { inbox, sharedInbox } = await user.getUserFields(id, ['inbox', 'sharedInbox']);
|
||||||
|
if (sharedInbox || inbox) {
|
||||||
|
inboxes.add(sharedInbox || inbox);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
const cacheKey = [id, uri].join(';');
|
||||||
|
const cached = requestCache.get(cacheKey);
|
||||||
|
if (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,
|
||||||
|
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;
|
||||||
|
};
|
||||||
406
src/activitypub/mocks.js
Normal file
406
src/activitypub/mocks.js
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
'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) => {
|
||||||
|
// 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 {
|
||||||
|
url, preferredUsername, published, icon, image,
|
||||||
|
name, summary, followers, followerCount, followingCount,
|
||||||
|
postcount, inbox, endpoints,
|
||||||
|
} = actor;
|
||||||
|
preferredUsername = preferredUsername || slugify(name);
|
||||||
|
|
||||||
|
const { hostname } = new URL(actor.id);
|
||||||
|
|
||||||
|
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(),
|
||||||
|
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,
|
||||||
|
postcount,
|
||||||
|
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, sourceContent,
|
||||||
|
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;
|
||||||
|
|
||||||
|
content = sanitize(content, sanitizeConfig);
|
||||||
|
content = await activitypub.helpers.remoteAnchorToLocalProfile(content);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
uid,
|
||||||
|
pid,
|
||||||
|
// tid, --> purposely omitted
|
||||||
|
name,
|
||||||
|
content,
|
||||||
|
sourceContent,
|
||||||
|
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, 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`,
|
||||||
|
sharedInbox: `${nconf.get('url')}/inbox`,
|
||||||
|
|
||||||
|
type: 'Person',
|
||||||
|
name: username !== displayname ? fullname : username, // displayname is escaped, fullname is not
|
||||||
|
preferredUsername: userslug,
|
||||||
|
summary: aboutme,
|
||||||
|
icon: picture,
|
||||||
|
image: cover,
|
||||||
|
|
||||||
|
publicKey: {
|
||||||
|
id: `${nconf.get('url')}/uid/${uid}#key`,
|
||||||
|
owner: `${nconf.get('url')}/uid/${uid}`,
|
||||||
|
publicKeyPem: publicKey,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
Mocks.actors.category = async (cid) => {
|
||||||
|
let {
|
||||||
|
name, handle: preferredUsername, slug,
|
||||||
|
description: summary, backgroundImage,
|
||||||
|
} = await categories.getCategoryData(cid);
|
||||||
|
const publicKey = await activitypub.getPublicKey('cid', cid);
|
||||||
|
|
||||||
|
backgroundImage = backgroundImage || meta.config['brand:logo'] || `${nconf.get('relative_path')}/assets/logo.png`;
|
||||||
|
const filename = path.basename(utils.decodeHTMLEntities(backgroundImage));
|
||||||
|
backgroundImage = {
|
||||||
|
type: 'Image',
|
||||||
|
mediaType: mime.getType(filename),
|
||||||
|
url: `${nconf.get('url')}${utils.decodeHTMLEntities(backgroundImage)}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
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`,
|
||||||
|
sharedInbox: `${nconf.get('url')}/inbox`,
|
||||||
|
|
||||||
|
type: 'Group',
|
||||||
|
name,
|
||||||
|
preferredUsername,
|
||||||
|
summary,
|
||||||
|
icon: backgroundImage,
|
||||||
|
|
||||||
|
publicKey: {
|
||||||
|
id: `${nconf.get('url')}/category/${cid}#key`,
|
||||||
|
owner: `${nconf.get('url')}/category/${cid}`,
|
||||||
|
publicKeyPem: publicKey,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
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 { postData: parsed } = await plugins.hooks.fire('filter:parse.post', {
|
||||||
|
postData: post,
|
||||||
|
type: '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(({ id: href, slug: name }) => {
|
||||||
|
if (utils.isNumber(href)) { // local ref
|
||||||
|
name = name.toLowerCase(); // local slugs are always lowercase
|
||||||
|
href = `${nconf.get('url')}/user/${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 });
|
||||||
|
});
|
||||||
|
|
||||||
|
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: {} todo...
|
||||||
|
};
|
||||||
|
|
||||||
|
return object;
|
||||||
|
};
|
||||||
|
|
||||||
|
Mocks.tombstone = async properties => ({
|
||||||
|
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||||
|
type: 'Tombstone',
|
||||||
|
...properties,
|
||||||
|
});
|
||||||
447
src/activitypub/notes.js
Normal file
447
src/activitypub/notes.js
Normal file
@@ -0,0 +1,447 @@
|
|||||||
|
'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, traverses up to cache the entire threaded context
|
||||||
|
*
|
||||||
|
* Unfortunately, due to limitations and fragmentation of the existing ActivityPub landscape,
|
||||||
|
* retrieving the entire reply tree is not possible at this time.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const object = !activitypub.helpers.isUri(input) && input;
|
||||||
|
const id = object ? object.id : input;
|
||||||
|
|
||||||
|
const lockStatus = await lock(id, '[[error:activitypub.already-asserting]]');
|
||||||
|
if (!lockStatus) { // unable to achieve lock, stop processing.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chain = Array.from(await Notes.getParentChain(uid, input));
|
||||||
|
if (!chain.length) {
|
||||||
|
unlock(id);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainPost = chain[chain.length - 1];
|
||||||
|
let { pid: mainPid, tid, uid: authorId, timestamp, name, content, _activitypub } = mainPost;
|
||||||
|
const hasTid = !!tid;
|
||||||
|
|
||||||
|
// Update category if currently uncategorized
|
||||||
|
if (hasTid) {
|
||||||
|
const cid = await topics.getTopicField(tid, 'cid');
|
||||||
|
if (options.cid && cid === -1) {
|
||||||
|
// Move topic
|
||||||
|
await topics.tools.move(tid, { cid: options.cid, uid: 'system' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const members = await db.isSortedSetMembers(`tid:${tid}:posts`, chain.slice(0, -1).map(p => p.pid));
|
||||||
|
members.push(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 cid;
|
||||||
|
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
|
||||||
|
cid = options.cid || -1;
|
||||||
|
title = name || activitypub.helpers.generateTitle(utils.decodeHTMLEntities(content));
|
||||||
|
}
|
||||||
|
mainPid = utils.isNumber(mainPid) ? parseInt(mainPid, 10) : mainPid;
|
||||||
|
|
||||||
|
// Relation & privilege check for local categories
|
||||||
|
const hasRelation = uid || options.skipChecks || options.cid || hasTid || await assertRelation(chain[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 unprocessed = chain.map((post) => {
|
||||||
|
post.tid = tid; // add tid to post hash
|
||||||
|
return post;
|
||||||
|
}).filter((p, idx) => !members[idx]);
|
||||||
|
const count = unprocessed.length;
|
||||||
|
// winston.verbose(`[notes/assert] ${count} new note(s) found.`);
|
||||||
|
|
||||||
|
const [ids, timestamps] = [
|
||||||
|
unprocessed.map(n => (utils.isNumber(n.pid) ? parseInt(n.pid, 10) : n.pid)),
|
||||||
|
unprocessed.map(n => n.timestamp),
|
||||||
|
];
|
||||||
|
|
||||||
|
// mainPid doesn't belong in posts zset
|
||||||
|
if (ids.includes(mainPid)) {
|
||||||
|
const idx = ids.indexOf(mainPid);
|
||||||
|
ids.splice(idx, 1);
|
||||||
|
timestamps.splice(idx, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (maxTags && 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.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
unprocessed.reverse();
|
||||||
|
for (const post of unprocessed) {
|
||||||
|
const { to, cc, attachment } = post._activitypub;
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await Promise.all([
|
||||||
|
topics.reply(post),
|
||||||
|
Notes.updateLocalRecipients(post.pid, { to, cc }),
|
||||||
|
posts.attachments.update(post.pid, attachment),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Category announce
|
||||||
|
if (object && object.id === post.pid) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
const followers = await activitypub.notes.getCategoryFollowers(cid);
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await activitypub.send('cid', cid, followers, {
|
||||||
|
id: `${object.id}#activity/announce/${Date.now()}`,
|
||||||
|
type: 'Announce',
|
||||||
|
to: [`${nconf.get('url')}/category/${cid}/followers`],
|
||||||
|
cc: [activitypub._constants.publicAddress],
|
||||||
|
object,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 numFollowers = await activitypub.actors.getLocalFollowersCount(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 numFollowers > 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);
|
||||||
|
|
||||||
|
// winston.verbose(`[activitypub/syncUserInboxes] Syncing tid ${tid} with ${uids.size} inboxes`);
|
||||||
|
await Promise.all([
|
||||||
|
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]);
|
||||||
|
|
||||||
|
const recipientSets = pids.map(id => `post:${id}:recipients`);
|
||||||
|
const announcerSets = pids.map(id => `pid:${id}:announces`);
|
||||||
|
|
||||||
|
await db.deleteAll([...recipientSets, ...announcerSets]);
|
||||||
|
};
|
||||||
|
|
||||||
|
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.getKeys = async () => db.getSortedSetRange('analyticsKeys', 0, -1);
|
||||||
|
|
||||||
Analytics.pageView = async function (payload) {
|
Analytics.pageView = async function (payload) {
|
||||||
|
|||||||
334
src/api/activitypub.js
Normal file
334
src/api/activitypub.js
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
'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 utils = require('../utils');
|
||||||
|
|
||||||
|
const activitypubApi = module.exports;
|
||||||
|
|
||||||
|
function enabledCheck(next) {
|
||||||
|
return async function (caller, params) {
|
||||||
|
if (meta.config.activitypubEnabled) {
|
||||||
|
await next(caller, params);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
await activitypub.send(type, id, [actor], {
|
||||||
|
id: `${nconf.get('url')}/${type}/${id}#activity/follow/${handle}`,
|
||||||
|
type: 'Follow',
|
||||||
|
object: actor,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.sortedSetAdd(`followRequests:${type}.${id}`, Date.now(), 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 object = {
|
||||||
|
id: `${nconf.get('url')}/${type}/${id}#activity/follow/${handle}`,
|
||||||
|
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}/${Date.now()}`,
|
||||||
|
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),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
const followers = await db.getSortedSetMembers(`followersRemote:${uid}`);
|
||||||
|
let { to, cc } = object;
|
||||||
|
to = new Set(to);
|
||||||
|
cc = new Set(cc);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Announcers and their followers
|
||||||
|
if (pid) {
|
||||||
|
const announcers = (await activitypub.notes.announce.list({ pid })).map(({ actor }) => actor);
|
||||||
|
const announcersFollowers = (await user.getUsersFields(announcers, ['followersUrl']))
|
||||||
|
.filter(o => o.hasOwnProperty('followersUrl'))
|
||||||
|
.map(({ followersUrl }) => followersUrl);
|
||||||
|
[...announcers].forEach(uri => targets.add(uri));
|
||||||
|
[...announcers, ...announcersFollowers].forEach(uri => cc.add(uri));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
to: [...to],
|
||||||
|
cc: [...cc],
|
||||||
|
targets,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
activitypubApi.create.note = enabledCheck(async (caller, { pid }) => {
|
||||||
|
const post = (await posts.getPostSummaryByPids([pid], caller.uid, { stripTags: false })).pop();
|
||||||
|
if (!post) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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, { 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: `${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, followers] = await Promise.all([
|
||||||
|
activitypub.mocks.actors.user(uid),
|
||||||
|
db.getSortedSetMembers(`followersRemote:${caller.uid}`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await activitypub.send('uid', caller.uid, followers, {
|
||||||
|
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.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);
|
||||||
|
});
|
||||||
@@ -11,7 +11,7 @@ flagsApi.create = async (caller, data) => {
|
|||||||
throw new Error('[[error:invalid-data]]');
|
throw new Error('[[error:invalid-data]]');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { type, id, reason } = data;
|
const { type, id, reason, notifyRemote } = data;
|
||||||
|
|
||||||
await flags.validate({
|
await flags.validate({
|
||||||
uid: caller.uid,
|
uid: caller.uid,
|
||||||
@@ -19,7 +19,7 @@ flagsApi.create = async (caller, data) => {
|
|||||||
id: id,
|
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);
|
flags.notify(flagObj, caller.uid);
|
||||||
|
|
||||||
return flagObj;
|
return flagObj;
|
||||||
@@ -59,6 +59,24 @@ flagsApi.rescind = async ({ uid }, { flagId }) => {
|
|||||||
await flags.rescindReport(type, targetId, uid);
|
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) => {
|
flagsApi.appendNote = async (caller, data) => {
|
||||||
const allowed = await user.isPrivileged(caller.uid);
|
const allowed = await user.isPrivileged(caller.uid);
|
||||||
if (!allowed) {
|
if (!allowed) {
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ exports.postCommand = async function (caller, command, eventName, notification,
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function executeCommand(caller, command, eventName, notification, data) {
|
async function executeCommand(caller, command, eventName, notification, data) {
|
||||||
|
const api = require('.');
|
||||||
const result = await posts[command](data.pid, caller.uid);
|
const result = await posts[command](data.pid, caller.uid);
|
||||||
if (result && eventName) {
|
if (result && eventName) {
|
||||||
websockets.in(`uid_${caller.uid}`).emit(`posts.${command}`, result);
|
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') {
|
if (result && command === 'upvote') {
|
||||||
socketHelpers.upvote(result, notification);
|
socketHelpers.upvote(result, notification);
|
||||||
|
api.activitypub.like.note(caller, { pid: data.pid });
|
||||||
} else if (result && notification) {
|
} else if (result && notification) {
|
||||||
socketHelpers.sendNotificationToPostOwner(data.pid, caller.uid, command, notification);
|
socketHelpers.sendNotificationToPostOwner(data.pid, caller.uid, command, notification);
|
||||||
} else if (result && command === 'unvote') {
|
} else if (result && command === 'unvote') {
|
||||||
socketHelpers.rescindUpvoteNotification(data.pid, caller.uid);
|
socketHelpers.rescindUpvoteNotification(data.pid, caller.uid);
|
||||||
|
api.activitypub.undo.like(caller, { pid: data.pid });
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ module.exports = {
|
|||||||
categories: require('./categories'),
|
categories: require('./categories'),
|
||||||
search: require('./search'),
|
search: require('./search'),
|
||||||
flags: require('./flags'),
|
flags: require('./flags'),
|
||||||
|
activitypub: require('./activitypub'),
|
||||||
files: require('./files'),
|
files: require('./files'),
|
||||||
utils: require('./utils'),
|
utils: require('./utils'),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ const db = require('../database');
|
|||||||
const utils = require('../utils');
|
const utils = require('../utils');
|
||||||
const user = require('../user');
|
const user = require('../user');
|
||||||
const posts = require('../posts');
|
const posts = require('../posts');
|
||||||
const postsCache = require('../posts/cache');
|
|
||||||
const topics = require('../topics');
|
const topics = require('../topics');
|
||||||
const groups = require('../groups');
|
const groups = require('../groups');
|
||||||
const plugins = require('../plugins');
|
const plugins = require('../plugins');
|
||||||
const meta = require('../meta');
|
const meta = require('../meta');
|
||||||
const events = require('../events');
|
const events = require('../events');
|
||||||
const privileges = require('../privileges');
|
const privileges = require('../privileges');
|
||||||
|
const activitypub = require('../activitypub');
|
||||||
const apiHelpers = require('./helpers');
|
const apiHelpers = require('./helpers');
|
||||||
const websockets = require('../socket.io');
|
const websockets = require('../socket.io');
|
||||||
const socketHelpers = require('../socket.io/helpers');
|
const socketHelpers = require('../socket.io/helpers');
|
||||||
@@ -135,12 +135,14 @@ postsAPI.edit = async function (caller, data) {
|
|||||||
newTitle: validator.escape(String(editResult.topic.title)),
|
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, { extraFields: ['edited'] });
|
||||||
const returnData = { ...postObj[0], ...editResult.post };
|
const returnData = { ...postObj[0], ...editResult.post };
|
||||||
returnData.topic = { ...postObj[0].topic, ...editResult.post.topic };
|
returnData.topic = { ...postObj[0].topic, ...editResult.post.topic };
|
||||||
|
|
||||||
if (!editResult.post.deleted) {
|
if (!editResult.post.deleted) {
|
||||||
websockets.in(`topic_${editResult.topic.tid}`).emit('event:post_edited', editResult);
|
websockets.in(`topic_${editResult.topic.tid}`).emit('event:post_edited', editResult);
|
||||||
|
await require('.').activitypub.update.note(caller, { post: postObj[0] });
|
||||||
|
|
||||||
return returnData;
|
return returnData;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,6 +155,7 @@ postsAPI.edit = async function (caller, data) {
|
|||||||
|
|
||||||
const uids = _.uniq(_.flatten(memberData).concat(String(caller.uid)));
|
const uids = _.uniq(_.flatten(memberData).concat(String(caller.uid)));
|
||||||
uids.forEach(uid => websockets.in(`uid_${uid}`).emit('event:post_edited', editResult));
|
uids.forEach(uid => websockets.in(`uid_${uid}`).emit('event:post_edited', editResult));
|
||||||
|
|
||||||
return returnData;
|
return returnData;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -191,6 +194,11 @@ async function deleteOrRestore(caller, data, params) {
|
|||||||
tid: postData.tid,
|
tid: postData.tid,
|
||||||
ip: caller.ip,
|
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) {
|
async function deleteOrRestoreTopicOf(command, pid, caller) {
|
||||||
@@ -209,16 +217,22 @@ async function deleteOrRestoreTopicOf(command, pid, caller) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
postsAPI.purge = async function (caller, data) {
|
postsAPI.purge = async function (caller, data) {
|
||||||
if (!data || !parseInt(data.pid, 10)) {
|
if (!data || !data.pid) {
|
||||||
throw new Error('[[error:invalid-data]]');
|
throw new Error('[[error:invalid-data]]');
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = await isMainAndLastPost(data.pid);
|
const [exists, { isMain, isLast }] = await Promise.all([
|
||||||
if (results.isMain && !results.isLast) {
|
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]]');
|
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']);
|
const postData = await posts.getPostFields(data.pid, ['toPid', 'tid']);
|
||||||
postData.pid = data.pid;
|
postData.pid = data.pid;
|
||||||
|
|
||||||
@@ -226,8 +240,11 @@ postsAPI.purge = async function (caller, data) {
|
|||||||
if (!canPurge) {
|
if (!canPurge) {
|
||||||
throw new Error('[[error:no-privileges]]');
|
throw new Error('[[error:no-privileges]]');
|
||||||
}
|
}
|
||||||
postsCache.del(data.pid);
|
posts.clearCachedPost(data.pid);
|
||||||
await posts.purge(data.pid, caller.uid);
|
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);
|
websockets.in(`topic_${postData.tid}`).emit('event:post_purged', postData);
|
||||||
const topicData = await topics.getTopicFields(postData.tid, ['title', 'cid']);
|
const topicData = await topics.getTopicFields(postData.tid, ['title', 'cid']);
|
||||||
@@ -347,9 +364,13 @@ postsAPI.getUpvoters = async function (caller, data) {
|
|||||||
throw new Error('[[error:no-privileges]]');
|
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;
|
const cutoff = 6;
|
||||||
if (!upvotedUids.length) {
|
if (!uids.length) {
|
||||||
return {
|
return {
|
||||||
otherCount: 0,
|
otherCount: 0,
|
||||||
usernames: [],
|
usernames: [],
|
||||||
@@ -357,17 +378,41 @@ postsAPI.getUpvoters = async function (caller, data) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
let otherCount = 0;
|
let otherCount = 0;
|
||||||
if (upvotedUids.length > cutoff) {
|
if (uids.length > cutoff) {
|
||||||
otherCount = upvotedUids.length - (cutoff - 1);
|
otherCount = uids.length - (cutoff - 1);
|
||||||
upvotedUids = upvotedUids.slice(0, cutoff - 1);
|
uids = uids.slice(0, cutoff - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const usernames = await user.getUsernamesByUids(upvotedUids);
|
const usernames = await user.getUsernamesByUids(uids);
|
||||||
return {
|
return {
|
||||||
otherCount,
|
otherCount,
|
||||||
usernames,
|
usernames,
|
||||||
cutoff,
|
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) {
|
async function canSeeVotes(uid, cids) {
|
||||||
@@ -481,7 +526,7 @@ postsAPI.deleteDiff = async (caller, { pid, timestamp }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
postsAPI.getReplies = async (caller, { pid }) => {
|
postsAPI.getReplies = async (caller, { pid }) => {
|
||||||
if (!utils.isNumber(pid)) {
|
if (!utils.isNumber(pid) && !activitypub.helpers.isUri(pid)) {
|
||||||
throw new Error('[[error:invalid-data]]');
|
throw new Error('[[error:invalid-data]]');
|
||||||
}
|
}
|
||||||
const { uid } = caller;
|
const { uid } = caller;
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ searchApi.categories = async (caller, data) => {
|
|||||||
({ cids, matchedCids } = await findMatchedCids(caller.uid, data));
|
({ cids, matchedCids } = await findMatchedCids(caller.uid, data));
|
||||||
} else {
|
} else {
|
||||||
cids = await loadCids(caller.uid, data.parentCid);
|
cids = await loadCids(caller.uid, data.parentCid);
|
||||||
|
if (meta.config.activitypubEnabled) {
|
||||||
|
cids.unshift(-1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const visibleCategories = await controllersHelpers.getVisibleCategories({
|
const visibleCategories = await controllersHelpers.getVisibleCategories({
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const posts = require('../posts');
|
|||||||
const meta = require('../meta');
|
const meta = require('../meta');
|
||||||
const privileges = require('../privileges');
|
const privileges = require('../privileges');
|
||||||
|
|
||||||
|
const activitypubApi = require('./activitypub');
|
||||||
const apiHelpers = require('./helpers');
|
const apiHelpers = require('./helpers');
|
||||||
|
|
||||||
const { doTopicAction } = apiHelpers;
|
const { doTopicAction } = apiHelpers;
|
||||||
@@ -80,6 +81,12 @@ topicsAPI.create = async function (caller, data) {
|
|||||||
socketHelpers.emitToUids('event:new_topic', result.topicData, [caller.uid]);
|
socketHelpers.emitToUids('event:new_topic', result.topicData, [caller.uid]);
|
||||||
socketHelpers.notifyNew(caller.uid, 'newTopic', { posts: [result.postData], topic: result.topicData });
|
socketHelpers.notifyNew(caller.uid, 'newTopic', { posts: [result.postData], topic: result.topicData });
|
||||||
|
|
||||||
|
if (!isScheduling) {
|
||||||
|
setImmediate(() => {
|
||||||
|
activitypubApi.create.note(caller, { pid: result.postData.pid });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return result.topicData;
|
return result.topicData;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -113,6 +120,7 @@ topicsAPI.reply = async function (caller, data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
socketHelpers.notifyNew(caller.uid, 'newPost', result);
|
socketHelpers.notifyNew(caller.uid, 'newPost', result);
|
||||||
|
activitypubApi.create.note(caller, { pid: postData.pid });
|
||||||
|
|
||||||
return postObj[0];
|
return postObj[0];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -175,27 +175,11 @@ usersAPI.changePassword = async function (caller, data) {
|
|||||||
|
|
||||||
usersAPI.follow = async function (caller, data) {
|
usersAPI.follow = async function (caller, data) {
|
||||||
await user.follow(caller.uid, data.uid);
|
await user.follow(caller.uid, data.uid);
|
||||||
|
await user.onFollow(caller.uid, data.uid);
|
||||||
plugins.hooks.fire('action:user.follow', {
|
plugins.hooks.fire('action:user.follow', {
|
||||||
fromUid: caller.uid,
|
fromUid: caller.uid,
|
||||||
toUid: data.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/${data.uid}/followers`,
|
|
||||||
mergeId: 'notifications:user-started-following-you',
|
|
||||||
});
|
|
||||||
if (!notifObj) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
notifObj.user = userData;
|
|
||||||
await notifications.push(notifObj, [data.uid]);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
usersAPI.unfollow = async function (caller, data) {
|
usersAPI.unfollow = async function (caller, data) {
|
||||||
@@ -531,7 +515,7 @@ async function isPrivilegedOrSelfAndPasswordMatch(caller, data) {
|
|||||||
|
|
||||||
async function processDeletion({ uid, method, password, caller }) {
|
async function processDeletion({ uid, method, password, caller }) {
|
||||||
const isTargetAdmin = await user.isAdministrator(uid);
|
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);
|
const hasAdminPrivilege = await privileges.admin.can('admin:users', caller.uid);
|
||||||
|
|
||||||
if (isSelf && meta.config.allowAccountDelete !== 1) {
|
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) {
|
cache.set = function (key, value, ttl) {
|
||||||
if (!cache.enabled) {
|
if (!cache.enabled) {
|
||||||
return;
|
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) {
|
if (!cache.enabled) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const _ = require('lodash');
|
|||||||
|
|
||||||
const db = require('../database');
|
const db = require('../database');
|
||||||
const plugins = require('../plugins');
|
const plugins = require('../plugins');
|
||||||
|
const meta = require('../meta');
|
||||||
const privileges = require('../privileges');
|
const privileges = require('../privileges');
|
||||||
const utils = require('../utils');
|
const utils = require('../utils');
|
||||||
const slugify = require('../slugify');
|
const slugify = require('../slugify');
|
||||||
@@ -20,6 +21,7 @@ module.exports = function (Categories) {
|
|||||||
|
|
||||||
data.name = String(data.name || `Category ${cid}`);
|
data.name = String(data.name || `Category ${cid}`);
|
||||||
const slug = `${cid}/${slugify(data.name)}`;
|
const slug = `${cid}/${slugify(data.name)}`;
|
||||||
|
const handle = await Categories.generateHandle(slugify(data.name));
|
||||||
const smallestOrder = firstChild.length ? firstChild[0].score - 1 : 1;
|
const smallestOrder = firstChild.length ? firstChild[0].score - 1 : 1;
|
||||||
const order = data.order || smallestOrder; // If no order provided, place it at the top
|
const order = data.order || smallestOrder; // If no order provided, place it at the top
|
||||||
const colours = Categories.assignColours();
|
const colours = Categories.assignColours();
|
||||||
@@ -27,6 +29,7 @@ module.exports = function (Categories) {
|
|||||||
let category = {
|
let category = {
|
||||||
cid: cid,
|
cid: cid,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
|
handle,
|
||||||
description: data.description ? data.description : '',
|
description: data.description ? data.description : '',
|
||||||
descriptionParsed: data.descriptionParsed ? data.descriptionParsed : '',
|
descriptionParsed: data.descriptionParsed ? data.descriptionParsed : '',
|
||||||
icon: data.icon ? data.icon : '',
|
icon: data.icon ? data.icon : '',
|
||||||
@@ -91,7 +94,7 @@ module.exports = function (Categories) {
|
|||||||
['categories:name', 0, `${data.name.slice(0, 200).toLowerCase()}:${category.cid}`],
|
['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.modPrivileges, category.cid, ['administrators', 'Global Moderators']);
|
||||||
await privileges.categories.give(result.guestPrivileges, category.cid, ['guests', 'spiders']);
|
await privileges.categories.give(result.guestPrivileges, category.cid, ['guests', 'spiders']);
|
||||||
|
|
||||||
@@ -146,6 +149,19 @@ module.exports = function (Categories) {
|
|||||||
await async.each(children, Categories.create);
|
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 () {
|
Categories.assignColours = function () {
|
||||||
const backgrounds = ['#AB4642', '#DC9656', '#F7CA88', '#A1B56C', '#86C1B9', '#7CAFC2', '#BA8BAF', '#A16946'];
|
const backgrounds = ['#AB4642', '#DC9656', '#F7CA88', '#A1B56C', '#86C1B9', '#7CAFC2', '#BA8BAF', '#A16946'];
|
||||||
const text = ['#ffffff', '#ffffff', '#333333', '#ffffff', '#333333', '#ffffff', '#ffffff', '#ffffff'];
|
const text = ['#ffffff', '#ffffff', '#333333', '#ffffff', '#333333', '#ffffff', '#ffffff', '#ffffff'];
|
||||||
|
|||||||
@@ -13,14 +13,45 @@ const intFields = [
|
|||||||
'minTags', 'maxTags', 'postQueue', 'subCategoriesPerPage',
|
'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) {
|
module.exports = function (Categories) {
|
||||||
Categories.getCategoriesFields = async function (cids, fields) {
|
Categories.getCategoriesFields = async function (cids, fields) {
|
||||||
if (!Array.isArray(cids) || !cids.length) {
|
if (!Array.isArray(cids) || !cids.length) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cids = cids.map(cid => parseInt(cid, 10));
|
||||||
const keys = cids.map(cid => `category:${cid}`);
|
const keys = cids.map(cid => `category:${cid}`);
|
||||||
const categories = await db.getObjects(keys, fields);
|
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', {
|
const result = await plugins.hooks.fire('filter:category.getFields', {
|
||||||
cids: cids,
|
cids: cids,
|
||||||
categories: categories,
|
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) {
|
Categories.getCategoryById = async function (data) {
|
||||||
const categories = await Categories.getCategories([data.cid]);
|
const categories = await Categories.getCategories([data.cid]);
|
||||||
if (!categories[0]) {
|
if (!categories[0]) {
|
||||||
@@ -67,6 +74,10 @@ Categories.getCategoryById = async function (data) {
|
|||||||
return { ...result.category };
|
return { ...result.category };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Categories.getCidByHandle = async function (handle) {
|
||||||
|
return await db.sortedSetScore('categoryhandle:cid', handle);
|
||||||
|
};
|
||||||
|
|
||||||
Categories.getAllCidsFromSet = async function (key) {
|
Categories.getAllCidsFromSet = async function (key) {
|
||||||
let cids = cache.get(key);
|
let cids = cache.get(key);
|
||||||
if (cids) {
|
if (cids) {
|
||||||
@@ -86,6 +97,10 @@ Categories.getAllCategories = async function () {
|
|||||||
|
|
||||||
Categories.getCidsByPrivilege = async function (set, uid, privilege) {
|
Categories.getCidsByPrivilege = async function (set, uid, privilege) {
|
||||||
const cids = await Categories.getAllCidsFromSet(set);
|
const cids = await Categories.getAllCidsFromSet(set);
|
||||||
|
if (set === 'categories:cid') {
|
||||||
|
cids.unshift(-1);
|
||||||
|
}
|
||||||
|
|
||||||
return await privileges.categories.filterCids(privilege, cids, uid);
|
return await privileges.categories.filterCids(privilege, cids, uid);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ module.exports = function (Categories) {
|
|||||||
return await updateTagWhitelist(cid, value);
|
return await updateTagWhitelist(cid, value);
|
||||||
} else if (key === 'name') {
|
} else if (key === 'name') {
|
||||||
return await updateName(cid, value);
|
return await updateName(cid, value);
|
||||||
|
} else if (key === 'handle') {
|
||||||
|
return await updateHandle(cid, value);
|
||||||
} else if (key === 'order') {
|
} else if (key === 'order') {
|
||||||
return await updateOrder(cid, value);
|
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.sortedSetAdd('categories:name', 0, `${newName.slice(0, 200).toLowerCase()}:${cid}`);
|
||||||
await db.setObjectField(`category:${cid}`, 'name', newName);
|
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 db = require('../database');
|
||||||
const user = require('../user');
|
const user = require('../user');
|
||||||
|
const activitypub = require('../activitypub');
|
||||||
|
|
||||||
module.exports = function (Categories) {
|
module.exports = function (Categories) {
|
||||||
Categories.watchStates = {
|
Categories.watchStates = {
|
||||||
@@ -20,7 +21,7 @@ module.exports = function (Categories) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
Categories.getWatchState = async function (cids, uid) {
|
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);
|
return cids.map(() => Categories.watchStates.notwatching);
|
||||||
}
|
}
|
||||||
if (!Array.isArray(cids) || !cids.length) {
|
if (!Array.isArray(cids) || !cids.length) {
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ const privileges = require('../../privileges');
|
|||||||
const translator = require('../../translator');
|
const translator = require('../../translator');
|
||||||
const messaging = require('../../messaging');
|
const messaging = require('../../messaging');
|
||||||
const categories = require('../../categories');
|
const categories = require('../../categories');
|
||||||
|
const posts = require('../../posts');
|
||||||
|
const activitypub = require('../../activitypub');
|
||||||
|
const flags = require('../../flags');
|
||||||
|
|
||||||
const relative_path = nconf.get('relative_path');
|
const relative_path = nconf.get('relative_path');
|
||||||
|
|
||||||
@@ -24,7 +27,12 @@ helpers.getUserDataByUserSlug = async function (userslug, callerUID, query = {})
|
|||||||
return null;
|
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) {
|
if (!results.userData) {
|
||||||
throw new Error('[[error:invalid-uid]]');
|
throw new Error('[[error:invalid-uid]]');
|
||||||
}
|
}
|
||||||
@@ -74,7 +82,8 @@ helpers.getUserDataByUserSlug = async function (userslug, callerUID, query = {})
|
|||||||
userData.canEdit = results.canEdit;
|
userData.canEdit = results.canEdit;
|
||||||
userData.canBan = results.canBanUser;
|
userData.canBan = results.canBanUser;
|
||||||
userData.canMute = results.canMuteUser;
|
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.canChangePassword = isAdmin || (isSelf && !meta.config['password:disableEdit']);
|
||||||
userData.isSelf = isSelf;
|
userData.isSelf = isSelf;
|
||||||
userData.isFollowing = results.isFollowing;
|
userData.isFollowing = results.isFollowing;
|
||||||
@@ -181,6 +190,7 @@ async function canChat(callerUID, uid) {
|
|||||||
|
|
||||||
async function getCounts(userData, callerUID) {
|
async function getCounts(userData, callerUID) {
|
||||||
const { uid } = userData;
|
const { uid } = userData;
|
||||||
|
const isRemote = activitypub.helpers.isUri(uid);
|
||||||
const cids = await categories.getCidsByPrivilege('categories:cid', callerUID, 'topics:read');
|
const cids = await categories.getCidsByPrivilege('categories:cid', callerUID, 'topics:read');
|
||||||
const promises = {
|
const promises = {
|
||||||
posts: db.sortedSetsCardSum(cids.map(c => `cid:${c}:uid:${uid}:pids`)),
|
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');
|
promises.blocks = user.getUserField(userData.uid, 'blocksCount');
|
||||||
}
|
}
|
||||||
const counts = await utils.promiseParallel(promises);
|
const counts = await utils.promiseParallel(promises);
|
||||||
|
counts.posts = isRemote ? userData.postcount : counts.posts;
|
||||||
counts.categoriesWatched = counts.categoriesWatched && counts.categoriesWatched.length;
|
counts.categoriesWatched = counts.categoriesWatched && counts.categoriesWatched.length;
|
||||||
counts.groups = userData.groups.length;
|
counts.groups = userData.groups.length;
|
||||||
counts.following = userData.followingCount;
|
counts.following = userData.followingCount;
|
||||||
@@ -273,7 +284,12 @@ async function parseAboutMe(userData) {
|
|||||||
userData.aboutme = '';
|
userData.aboutme = '';
|
||||||
userData.aboutmeParsed = '';
|
userData.aboutmeParsed = '';
|
||||||
return;
|
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 || ''));
|
userData.aboutme = validator.escape(String(userData.aboutme || ''));
|
||||||
const parsed = await plugins.hooks.fire('filter:parse.aboutme', userData.aboutme);
|
const parsed = await plugins.hooks.fire('filter:parse.aboutme', userData.aboutme);
|
||||||
userData.aboutme = translator.escape(userData.aboutme);
|
userData.aboutme = translator.escape(userData.aboutme);
|
||||||
|
|||||||
@@ -177,20 +177,27 @@ async function getPostsFromUserSet(template, req, res) {
|
|||||||
const data = templateToData[template];
|
const data = templateToData[template];
|
||||||
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
|
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 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 settings = await user.getSettings(req.uid);
|
||||||
|
|
||||||
const itemsPerPage = data.type === 'topics' ? settings.topicsPerPage : settings.postsPerPage;
|
const itemsPerPage = data.type === 'topics' ? settings.topicsPerPage : settings.postsPerPage;
|
||||||
const start = (page - 1) * itemsPerPage;
|
const start = (page - 1) * itemsPerPage;
|
||||||
const stop = start + itemsPerPage - 1;
|
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;
|
let result;
|
||||||
if (plugins.hooks.hasListeners('filter:account.getPostsFromUserSet')) {
|
if (plugins.hooks.hasListeners('filter:account.getPostsFromUserSet')) {
|
||||||
result = await plugins.hooks.fire('filter:account.getPostsFromUserSet', {
|
result = await plugins.hooks.fire('filter:account.getPostsFromUserSet', {
|
||||||
req: req,
|
req: req,
|
||||||
template: template,
|
template: template,
|
||||||
userData: { uid: res.locals.uid, username, userslug },
|
userData: { uid, username, userslug },
|
||||||
settings: settings,
|
settings: settings,
|
||||||
data: data,
|
data: data,
|
||||||
start: start,
|
start: start,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const _ = require('lodash');
|
const _ = require('lodash');
|
||||||
|
const nconf = require('nconf');
|
||||||
|
|
||||||
const db = require('../../database');
|
const db = require('../../database');
|
||||||
|
const meta = require('../../meta');
|
||||||
const user = require('../../user');
|
const user = require('../../user');
|
||||||
const posts = require('../../posts');
|
const posts = require('../../posts');
|
||||||
const categories = require('../../categories');
|
const categories = require('../../categories');
|
||||||
@@ -41,7 +43,12 @@ profileController.get = async function (req, res, next) {
|
|||||||
userData.profileviews = 1;
|
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);
|
res.render('account/profile', userData);
|
||||||
};
|
};
|
||||||
@@ -112,7 +119,7 @@ async function getPosts(callerUid, userData, setSuffix) {
|
|||||||
return postData.slice(0, count);
|
return postData.slice(0, count);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addMetaTags(res, userData) {
|
function addTags(res, userData) {
|
||||||
const plainAboutMe = userData.aboutme ? utils.stripHTMLTags(utils.decodeHTMLEntities(userData.aboutme)) : '';
|
const plainAboutMe = userData.aboutme ? utils.stripHTMLTags(utils.decodeHTMLEntities(userData.aboutme)) : '';
|
||||||
res.locals.metaTags = [
|
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}`,
|
||||||
|
}];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
137
src/controllers/activitypub/actors.js
Normal file
137
src/controllers/activitypub/actors.js
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const nconf = require('nconf');
|
||||||
|
|
||||||
|
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',
|
||||||
|
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.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
let page = parseInt(req.query.page, 10);
|
||||||
|
const { cid, titleRaw: name, mainPid, slug, postcount } = await topics.getTopicFields(req.params.tid, ['cid', 'title', 'mainPid', 'slug', 'postcount']);
|
||||||
|
const pageCount = Math.max(1, Math.ceil(postcount / meta.config.postsPerPage));
|
||||||
|
let items;
|
||||||
|
let paginate = true;
|
||||||
|
|
||||||
|
if (!page && pageCount === 1) {
|
||||||
|
page = 1;
|
||||||
|
paginate = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (page) {
|
||||||
|
const invalidPagination = page < 1 || page > pageCount;
|
||||||
|
if (invalidPagination) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = Math.max(0, ((page - 1) * meta.config.postsPerPage) - 1);
|
||||||
|
const stop = Math.max(0, start + meta.config.postsPerPage - 1);
|
||||||
|
const pids = await posts.getPidsFromSet(`tid:${req.params.tid}:posts`, start, stop);
|
||||||
|
if (page === 1) {
|
||||||
|
pids.unshift(mainPid);
|
||||||
|
pids.length = Math.min(pids.length, meta.config.postsPerPage);
|
||||||
|
}
|
||||||
|
items = pids.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}${paginate && page ? `?page=${page}` : ''}`,
|
||||||
|
url: `${nconf.get('url')}/topic/${slug}`,
|
||||||
|
name,
|
||||||
|
type: paginate && items ? 'OrderedCollectionPage' : 'OrderedCollection',
|
||||||
|
audience: `${nconf.get('url')}/category/${cid}`,
|
||||||
|
totalItems: postcount,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (items) {
|
||||||
|
object.items = items;
|
||||||
|
|
||||||
|
if (paginate) {
|
||||||
|
object.partOf = `${nconf.get('url')}/topic/${req.params.tid}`;
|
||||||
|
object.next = page < pageCount ? `${nconf.get('url')}/topic/${req.params.tid}?page=${page + 1}` : null;
|
||||||
|
object.prev = page > 1 ? `${nconf.get('url')}/topic/${req.params.tid}?page=${page - 1}` : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paginate) {
|
||||||
|
object.first = `${nconf.get('url')}/topic/${req.params.tid}?page=1`;
|
||||||
|
object.last = `${nconf.get('url')}/topic/${req.params.tid}?page=${pageCount}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json(object);
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
102
src/controllers/activitypub/topics.js
Normal file
102
src/controllers/activitypub/topics.js
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
'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;
|
||||||
|
|
||||||
|
const sets = [sortToSet[sort], `uid:${req.uid}:inbox`];
|
||||||
|
const tids = await db.getSortedSetRevIntersect({
|
||||||
|
sets,
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
weights: sets.map((s, index) => (index ? 0 : 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 = await db.sortedSetIntersectCard(sets);
|
||||||
|
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 _ = require('lodash');
|
||||||
const nconf = require('nconf');
|
const nconf = require('nconf');
|
||||||
|
const db = require('../../database');
|
||||||
|
const user = require('../../user');
|
||||||
const categories = require('../../categories');
|
const categories = require('../../categories');
|
||||||
const analytics = require('../../analytics');
|
const analytics = require('../../analytics');
|
||||||
const plugins = require('../../plugins');
|
const plugins = require('../../plugins');
|
||||||
const translator = require('../../translator');
|
const translator = require('../../translator');
|
||||||
const meta = require('../../meta');
|
const meta = require('../../meta');
|
||||||
|
const activitypub = require('../../activitypub');
|
||||||
const helpers = require('../helpers');
|
const helpers = require('../helpers');
|
||||||
const pagination = require('../../pagination');
|
const pagination = require('../../pagination');
|
||||||
|
|
||||||
@@ -145,3 +148,31 @@ categoriesController.getAnalytics = async function (req, res) {
|
|||||||
selectedCategory: selectedData.selectedCategory,
|
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 categories = require('../../categories');
|
||||||
const privileges = require('../../privileges');
|
const privileges = require('../../privileges');
|
||||||
|
const utils = require('../../utils');
|
||||||
|
|
||||||
const privilegesController = module.exports;
|
const privilegesController = module.exports;
|
||||||
|
|
||||||
@@ -10,10 +11,10 @@ privilegesController.get = async function (req, res) {
|
|||||||
const isAdminPriv = req.params.cid === 'admin';
|
const isAdminPriv = req.params.cid === 'admin';
|
||||||
|
|
||||||
let privilegesData;
|
let privilegesData;
|
||||||
if (cid > 0) {
|
if (cid === 0) {
|
||||||
privilegesData = await privileges.categories.list(cid);
|
|
||||||
} else if (cid === 0) {
|
|
||||||
privilegesData = await (isAdminPriv ? privileges.admin.list(req.uid) : privileges.global.list());
|
privilegesData = await (isAdminPriv ? privileges.admin.list(req.uid) : privileges.global.list());
|
||||||
|
} else if (utils.isNumber(cid)) {
|
||||||
|
privilegesData = await privileges.categories.list(cid);
|
||||||
}
|
}
|
||||||
|
|
||||||
const categoriesData = [{
|
const categoriesData = [{
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const groups = require('../../groups');
|
|||||||
const languages = require('../../languages');
|
const languages = require('../../languages');
|
||||||
const navigationAdmin = require('../../navigation/admin');
|
const navigationAdmin = require('../../navigation/admin');
|
||||||
const social = require('../../social');
|
const social = require('../../social');
|
||||||
|
const activitypub = require('../../activitypub');
|
||||||
const api = require('../../api');
|
const api = require('../../api');
|
||||||
const pagination = require('../../pagination');
|
const pagination = require('../../pagination');
|
||||||
const helpers = require('../helpers');
|
const helpers = require('../helpers');
|
||||||
@@ -123,3 +124,12 @@ settingsController.api = async (req, res) => {
|
|||||||
pagination: pagination.create(page, pageCount, req.query),
|
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,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ const validSorts = [
|
|||||||
|
|
||||||
categoryController.get = async function (req, res, next) {
|
categoryController.get = async function (req, res, next) {
|
||||||
const cid = req.params.category_id;
|
const cid = req.params.category_id;
|
||||||
|
if (cid === '-1') {
|
||||||
|
return helpers.redirect(res, `${res.locals.isAPI ? '/api' : ''}/world?${qs.stringify(req.query)}`);
|
||||||
|
}
|
||||||
|
|
||||||
let currentPage = parseInt(req.query.page, 10) || 1;
|
let currentPage = parseInt(req.query.page, 10) || 1;
|
||||||
let topicIndex = utils.isNumber(req.params.topic_index) ? parseInt(req.params.topic_index, 10) - 1 : 0;
|
let topicIndex = utils.isNumber(req.params.topic_index) ? parseInt(req.params.topic_index, 10) - 1 : 0;
|
||||||
@@ -155,6 +158,11 @@ categoryController.get = async function (req, res, next) {
|
|||||||
|
|
||||||
analytics.increment([`pageviews:byCid:${categoryData.cid}`]);
|
analytics.increment([`pageviews:byCid:${categoryData.cid}`]);
|
||||||
|
|
||||||
|
if (meta.config.activitypubEnabled) {
|
||||||
|
// Include link header for richer parsing
|
||||||
|
res.set('Link', `<${nconf.get('url')}/actegory/${cid}>; rel="alternate"; type="application/activity+json"`);
|
||||||
|
}
|
||||||
|
|
||||||
res.render('category', categoryData);
|
res.render('category', categoryData);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -226,4 +234,12 @@ function addTags(categoryData, res, currentPage) {
|
|||||||
href: categoryData.rssFeedUrl,
|
href: categoryData.rssFeedUrl,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (meta.config.activitypubEnabled) {
|
||||||
|
res.locals.linkTags.push({
|
||||||
|
rel: 'alternate',
|
||||||
|
type: 'application/activity+json',
|
||||||
|
href: `${nconf.get('url')}/actegory/${categoryData.cid}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ const helpers = require('./helpers');
|
|||||||
const Controllers = module.exports;
|
const Controllers = module.exports;
|
||||||
|
|
||||||
Controllers.ping = require('./ping');
|
Controllers.ping = require('./ping');
|
||||||
|
Controllers['well-known'] = require('./well-known');
|
||||||
|
Controllers.activitypub = require('./activitypub');
|
||||||
Controllers.home = require('./home');
|
Controllers.home = require('./home');
|
||||||
Controllers.topics = require('./topics');
|
Controllers.topics = require('./topics');
|
||||||
Controllers.posts = require('./posts');
|
Controllers.posts = require('./posts');
|
||||||
|
|||||||
@@ -1,19 +1,32 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
const nconf = require('nconf');
|
||||||
const querystring = require('querystring');
|
const querystring = require('querystring');
|
||||||
|
|
||||||
|
const meta = require('../meta');
|
||||||
const posts = require('../posts');
|
const posts = require('../posts');
|
||||||
const privileges = require('../privileges');
|
const privileges = require('../privileges');
|
||||||
|
const activitypub = require('../activitypub');
|
||||||
|
const utils = require('../utils');
|
||||||
|
|
||||||
const helpers = require('./helpers');
|
const helpers = require('./helpers');
|
||||||
|
|
||||||
const postsController = module.exports;
|
const postsController = module.exports;
|
||||||
|
|
||||||
postsController.redirectToPost = async function (req, res, next) {
|
postsController.redirectToPost = async function (req, res, next) {
|
||||||
const pid = parseInt(req.params.pid, 10);
|
const pid = utils.isNumber(req.params.pid) ? parseInt(req.params.pid, 10) : req.params.pid;
|
||||||
if (!pid) {
|
if (!pid) {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kickstart note assertion if applicable
|
||||||
|
if (!utils.isNumber(pid) && req.uid && meta.config.activitypubEnabled) {
|
||||||
|
const exists = await posts.exists(pid);
|
||||||
|
if (!exists) {
|
||||||
|
await activitypub.notes.assert(req.uid, pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const [canRead, path] = await Promise.all([
|
const [canRead, path] = await Promise.all([
|
||||||
privileges.posts.can('topics:read', pid, req.uid),
|
privileges.posts.can('topics:read', pid, req.uid),
|
||||||
posts.generatePostPath(pid, req.uid),
|
posts.generatePostPath(pid, req.uid),
|
||||||
@@ -25,6 +38,11 @@ postsController.redirectToPost = async function (req, res, next) {
|
|||||||
return helpers.notAllowed(req, res);
|
return helpers.notAllowed(req, res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (meta.config.activitypubEnabled) {
|
||||||
|
// Include link header for richer parsing
|
||||||
|
res.set('Link', `<${nconf.get('url')}/post/${req.params.pid}>; rel="alternate"; type="application/activity+json"`);
|
||||||
|
}
|
||||||
|
|
||||||
const qs = querystring.stringify(req.query);
|
const qs = querystring.stringify(req.query);
|
||||||
helpers.redirect(res, qs ? `${path}?${qs}` : path, true);
|
helpers.redirect(res, qs ? `${path}?${qs}` : path, true);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ recentController.getData = async function (req, url, sort) {
|
|||||||
data.title = meta.config.homePageTitle || '[[pages:home]]';
|
data.title = meta.config.homePageTitle || '[[pages:home]]';
|
||||||
} else {
|
} else {
|
||||||
data.title = `[[pages:${url}]]`;
|
data.title = `[[pages:${url}]]`;
|
||||||
data.breadcrumbs = helpers.buildBreadcrumbs([{ text: `[[${url}:title]]` }]);
|
data.breadcrumbs = helpers.buildBreadcrumbs([{ text: `[[activitypub:world-title]]` }]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const query = { ...req.query };
|
const query = { ...req.query };
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ tagsController.getTag = async function (req, res) {
|
|||||||
breadcrumbs: helpers.buildBreadcrumbs([{ text: '[[tags:tags]]', url: '/tags' }, { text: tag }]),
|
breadcrumbs: helpers.buildBreadcrumbs([{ text: '[[tags:tags]]', url: '/tags' }, { text: tag }]),
|
||||||
title: `[[pages:tag, ${tag}]]`,
|
title: `[[pages:tag, ${tag}]]`,
|
||||||
};
|
};
|
||||||
const [settings, cids, categoryData, canPost, isPrivileged, rssToken, isFollowing] = await Promise.all([
|
let [settings, cids, categoryData, canPost, isPrivileged, rssToken, isFollowing] = await Promise.all([
|
||||||
user.getSettings(req.uid),
|
user.getSettings(req.uid),
|
||||||
cid || categories.getCidsByPrivilege('categories:cid', req.uid, 'topics:read'),
|
cid || categories.getCidsByPrivilege('categories:cid', req.uid, 'topics:read'),
|
||||||
helpers.getSelectedCategory(cid),
|
helpers.getSelectedCategory(cid),
|
||||||
@@ -34,6 +34,14 @@ tagsController.getTag = async function (req, res) {
|
|||||||
user.auth.getFeedToken(req.uid),
|
user.auth.getFeedToken(req.uid),
|
||||||
topics.isFollowingTag(req.params.tag, req.uid),
|
topics.isFollowingTag(req.params.tag, req.uid),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Explicitly exclude cid -1 if cid not specified
|
||||||
|
if (!cid) {
|
||||||
|
cids = new Set(cids);
|
||||||
|
cids.delete(-1);
|
||||||
|
cids = Array.from(cids);
|
||||||
|
}
|
||||||
|
|
||||||
const start = Math.max(0, (page - 1) * settings.topicsPerPage);
|
const start = Math.max(0, (page - 1) * settings.topicsPerPage);
|
||||||
const stop = start + settings.topicsPerPage - 1;
|
const stop = start + settings.topicsPerPage - 1;
|
||||||
|
|
||||||
@@ -83,7 +91,8 @@ tagsController.getTag = async function (req, res) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
tagsController.getTags = async function (req, res) {
|
tagsController.getTags = async function (req, res) {
|
||||||
const cids = await categories.getCidsByPrivilege('categories:cid', req.uid, 'topics:read');
|
let cids = await categories.getCidsByPrivilege('categories:cid', req.uid, 'topics:read');
|
||||||
|
cids = cids.filter(cid => cid !== -1);
|
||||||
const [canSearch, tags] = await Promise.all([
|
const [canSearch, tags] = await Promise.all([
|
||||||
privileges.global.can('search:tags', req.uid),
|
privileges.global.can('search:tags', req.uid),
|
||||||
topics.getCategoryTagsData(cids, 0, 99),
|
topics.getCategoryTagsData(cids, 0, 99),
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ topicsController.get = async function getTopic(req, res, next) {
|
|||||||
const tid = req.params.topic_id;
|
const tid = req.params.topic_id;
|
||||||
if (
|
if (
|
||||||
(req.params.post_index && !utils.isNumber(req.params.post_index) && req.params.post_index !== 'unread') ||
|
(req.params.post_index && !utils.isNumber(req.params.post_index) && req.params.post_index !== 'unread') ||
|
||||||
!utils.isNumber(tid)
|
(!utils.isNumber(tid) && !validator.isUUID(tid))
|
||||||
) {
|
) {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
@@ -118,13 +118,16 @@ topicsController.get = async function getTopic(req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
topicData.postIndex = postIndex;
|
topicData.postIndex = postIndex;
|
||||||
|
const postAtIndex = topicData.posts.find(
|
||||||
|
p => parseInt(p.index, 10) === parseInt(Math.max(0, postIndex - 1), 10)
|
||||||
|
);
|
||||||
|
|
||||||
const [author] = await Promise.all([
|
const [author] = await Promise.all([
|
||||||
user.getUserFields(topicData.uid, ['username', 'userslug']),
|
user.getUserFields(topicData.uid, ['username', 'userslug']),
|
||||||
buildBreadcrumbs(topicData),
|
buildBreadcrumbs(topicData),
|
||||||
addOldCategory(topicData, userPrivileges),
|
addOldCategory(topicData, userPrivileges),
|
||||||
addTags(topicData, req, res, currentPage),
|
addTags(topicData, req, res, currentPage, postAtIndex),
|
||||||
incrementViewCount(req, tid),
|
topics.increaseViewCount(req, tid),
|
||||||
markAsRead(req, tid),
|
markAsRead(req, tid),
|
||||||
analytics.increment([`pageviews:byCid:${topicData.category.cid}`]),
|
analytics.increment([`pageviews:byCid:${topicData.category.cid}`]),
|
||||||
]);
|
]);
|
||||||
@@ -135,6 +138,14 @@ topicsController.get = async function getTopic(req, res, next) {
|
|||||||
rel.href = `${url}/topic/${topicData.slug}${rel.href}`;
|
rel.href = `${url}/topic/${topicData.slug}${rel.href}`;
|
||||||
res.locals.linkTags.push(rel);
|
res.locals.linkTags.push(rel);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (meta.config.activitypubEnabled && postAtIndex) {
|
||||||
|
// Include link header for richer parsing
|
||||||
|
const { pid } = postAtIndex;
|
||||||
|
const href = utils.isNumber(pid) ? `${nconf.get('url')}/post/${pid}` : pid;
|
||||||
|
res.set('Link', `<${href}>; rel="alternate"; type="application/activity+json"`);
|
||||||
|
}
|
||||||
|
|
||||||
res.render('topic', topicData);
|
res.render('topic', topicData);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -162,19 +173,6 @@ function calculateStartStop(page, postIndex, settings) {
|
|||||||
return { start: Math.max(0, start), stop: Math.max(0, stop) };
|
return { start: Math.max(0, start), stop: Math.max(0, stop) };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function incrementViewCount(req, tid) {
|
|
||||||
const allow = req.uid > 0 || (meta.config.guestsIncrementTopicViews && req.uid === 0);
|
|
||||||
if (allow) {
|
|
||||||
req.session.tids_viewed = req.session.tids_viewed || {};
|
|
||||||
const now = Date.now();
|
|
||||||
const interval = meta.config.incrementTopicViewsInterval * 60000;
|
|
||||||
if (!req.session.tids_viewed[tid] || req.session.tids_viewed[tid] < now - interval) {
|
|
||||||
await topics.increaseViewCount(tid);
|
|
||||||
req.session.tids_viewed[tid] = now;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function markAsRead(req, tid) {
|
async function markAsRead(req, tid) {
|
||||||
if (req.loggedIn) {
|
if (req.loggedIn) {
|
||||||
const markedRead = await topics.markAsRead([tid], req.uid);
|
const markedRead = await topics.markAsRead([tid], req.uid);
|
||||||
@@ -209,9 +207,7 @@ async function addOldCategory(topicData, userPrivileges) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addTags(topicData, req, res, currentPage) {
|
async function addTags(topicData, req, res, currentPage, postAtIndex) {
|
||||||
const postIndex = parseInt(req.params.post_index, 10) || 0;
|
|
||||||
const postAtIndex = topicData.posts.find(p => parseInt(p.index, 10) === parseInt(Math.max(0, postIndex - 1), 10));
|
|
||||||
let description = '';
|
let description = '';
|
||||||
if (postAtIndex && postAtIndex.content) {
|
if (postAtIndex && postAtIndex.content) {
|
||||||
description = utils.stripHTMLTags(utils.decodeHTMLEntities(postAtIndex.content)).trim();
|
description = utils.stripHTMLTags(utils.decodeHTMLEntities(postAtIndex.content)).trim();
|
||||||
@@ -299,6 +295,15 @@ async function addTags(topicData, req, res, currentPage) {
|
|||||||
href: `${url}/user/${postAtIndex.user.userslug}`,
|
href: `${url}/user/${postAtIndex.user.userslug}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (meta.config.activitypubEnabled && postAtIndex) {
|
||||||
|
const { pid } = postAtIndex;
|
||||||
|
res.locals.linkTags.push({
|
||||||
|
rel: 'alternate',
|
||||||
|
type: 'application/activity+json',
|
||||||
|
href: utils.isNumber(pid) ? `${nconf.get('url')}/post/${pid}` : pid,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addOGImageTags(res, topicData, postAtIndex) {
|
async function addOGImageTags(res, topicData, postAtIndex) {
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ usersController.getUsersAndCount = async function (set, uid, start, stop) {
|
|||||||
getCount(),
|
getCount(),
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
users: usersData.filter(user => user && parseInt(user.uid, 10)),
|
users: usersData.filter(Boolean),
|
||||||
count: count,
|
count: count,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
112
src/controllers/well-known.js
Normal file
112
src/controllers/well-known.js
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const nconf = require('nconf');
|
||||||
|
|
||||||
|
const meta = require('../meta');
|
||||||
|
const user = require('../user');
|
||||||
|
const categories = require('../categories');
|
||||||
|
const privileges = require('../privileges');
|
||||||
|
|
||||||
|
const Controller = module.exports;
|
||||||
|
|
||||||
|
Controller.webfinger = async (req, res) => {
|
||||||
|
const { resource } = req.query;
|
||||||
|
const { host, hostname } = nconf.get('url_parsed');
|
||||||
|
|
||||||
|
if (!resource || !resource.startsWith('acct:') || !resource.endsWith(host)) {
|
||||||
|
return res.sendStatus(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the slug
|
||||||
|
const slug = resource.slice(5, resource.length - (host.length + 1));
|
||||||
|
const [uid, cid] = await Promise.all([
|
||||||
|
user.getUidByUserslug(slug),
|
||||||
|
categories.getCidByHandle(slug),
|
||||||
|
]);
|
||||||
|
let response = {
|
||||||
|
subject: `acct:${slug}@${host}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (slug === hostname) {
|
||||||
|
response = application(response);
|
||||||
|
} else if (uid) {
|
||||||
|
response = await profile(req.uid, uid, response);
|
||||||
|
} else if (cid) {
|
||||||
|
response = await category(req.uid, cid, response);
|
||||||
|
} else {
|
||||||
|
return res.sendStatus(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json(response);
|
||||||
|
} catch (e) {
|
||||||
|
res.sendStatus(400);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function application(response) {
|
||||||
|
response.aliases = [nconf.get('url')];
|
||||||
|
response.links = [];
|
||||||
|
|
||||||
|
if (meta.config.activitypubEnabled) {
|
||||||
|
response.links.push({
|
||||||
|
rel: 'self',
|
||||||
|
type: 'application/activity+json',
|
||||||
|
href: `${nconf.get('url')}/actor`, // actor
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function profile(callerUid, uid, response) {
|
||||||
|
const canView = await privileges.global.can('view:users', callerUid);
|
||||||
|
if (!canView) {
|
||||||
|
throw new Error('[[error:no-privileges]]');
|
||||||
|
}
|
||||||
|
const slug = await user.getUserField(uid, 'userslug');
|
||||||
|
|
||||||
|
response.aliases = [
|
||||||
|
`${nconf.get('url')}/uid/${uid}`,
|
||||||
|
`${nconf.get('url')}/user/${slug}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
response.links = [
|
||||||
|
{
|
||||||
|
rel: 'http://webfinger.net/rel/profile-page',
|
||||||
|
type: 'text/html',
|
||||||
|
href: `${nconf.get('url')}/user/${slug}`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (meta.config.activitypubEnabled) {
|
||||||
|
response.links.push({
|
||||||
|
rel: 'self',
|
||||||
|
type: 'application/activity+json',
|
||||||
|
href: `${nconf.get('url')}/uid/${uid}`, // actor
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function category(callerUid, cid, response) {
|
||||||
|
const canFind = await privileges.categories.can('find', cid, callerUid);
|
||||||
|
if (!canFind) {
|
||||||
|
throw new Error('[[error:no-privileges]]');
|
||||||
|
}
|
||||||
|
const slug = await categories.getCategoryField(cid, 'slug');
|
||||||
|
|
||||||
|
response.aliases = [`${nconf.get('url')}/category/${slug}`];
|
||||||
|
response.links = [];
|
||||||
|
|
||||||
|
if (meta.config.activitypubEnabled) {
|
||||||
|
response.links.push({
|
||||||
|
rel: 'self',
|
||||||
|
type: 'application/activity+json',
|
||||||
|
href: `${nconf.get('url')}/category/${cid}`, // actor
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
@@ -105,3 +105,37 @@ Categories.setModerator = async (req, res) => {
|
|||||||
const privilegeSet = await api.categories.getPrivileges(req, { cid: req.params.cid });
|
const privilegeSet = await api.categories.getPrivileges(req, { cid: req.params.cid });
|
||||||
helpers.formatApiResponse(200, res, privilegeSet);
|
helpers.formatApiResponse(200, res, privilegeSet);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Categories.follow = async (req, res, next) => {
|
||||||
|
const { actor } = req.body;
|
||||||
|
const id = parseInt(req.params.cid, 10);
|
||||||
|
|
||||||
|
if (!id) { // disallow cid 0
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.activitypub.follow(req, {
|
||||||
|
type: 'cid',
|
||||||
|
id,
|
||||||
|
actor,
|
||||||
|
});
|
||||||
|
|
||||||
|
helpers.formatApiResponse(200, res, {});
|
||||||
|
};
|
||||||
|
|
||||||
|
Categories.unfollow = async (req, res, next) => {
|
||||||
|
const { actor } = req.body;
|
||||||
|
const id = parseInt(req.params.cid, 10);
|
||||||
|
|
||||||
|
if (!id) { // disallow cid 0
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.activitypub.unfollow(req, {
|
||||||
|
type: 'cid',
|
||||||
|
id,
|
||||||
|
actor,
|
||||||
|
});
|
||||||
|
|
||||||
|
helpers.formatApiResponse(200, res, {});
|
||||||
|
};
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ const helpers = require('../helpers');
|
|||||||
const Flags = module.exports;
|
const Flags = module.exports;
|
||||||
|
|
||||||
Flags.create = async (req, res) => {
|
Flags.create = async (req, res) => {
|
||||||
const { type, id, reason } = req.body;
|
const { type, id, reason, notifyRemote } = req.body;
|
||||||
const flagObj = await api.flags.create(req, { type, id, reason });
|
const flagObj = await api.flags.create(req, { type, id, reason, notifyRemote });
|
||||||
helpers.formatApiResponse(200, res, await user.isPrivileged(req.uid) ? flagObj : undefined);
|
helpers.formatApiResponse(200, res, await user.isPrivileged(req.uid) ? flagObj : undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -141,6 +141,16 @@ Posts.getUpvoters = async (req, res) => {
|
|||||||
helpers.formatApiResponse(200, res, data);
|
helpers.formatApiResponse(200, res, data);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Posts.getAnnouncers = async (req, res) => {
|
||||||
|
const data = await api.posts.getAnnouncers(req, { pid: req.params.pid, tooltip: 0 });
|
||||||
|
helpers.formatApiResponse(200, res, data);
|
||||||
|
};
|
||||||
|
|
||||||
|
Posts.getAnnouncersTooltip = async (req, res) => {
|
||||||
|
const data = await api.posts.getAnnouncers(req, { pid: req.params.pid, tooltip: 1 });
|
||||||
|
helpers.formatApiResponse(200, res, data);
|
||||||
|
};
|
||||||
|
|
||||||
Posts.bookmark = async (req, res) => {
|
Posts.bookmark = async (req, res) => {
|
||||||
const data = await mock(req);
|
const data = await mock(req);
|
||||||
await api.posts.bookmark(req, data);
|
await api.posts.bookmark(req, data);
|
||||||
|
|||||||
@@ -92,12 +92,31 @@ Users.changePassword = async (req, res) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
Users.follow = async (req, res) => {
|
Users.follow = async (req, res) => {
|
||||||
|
const remote = String(req.params.uid).includes('@');
|
||||||
|
if (remote) {
|
||||||
|
await api.activitypub.follow(req, {
|
||||||
|
type: 'uid',
|
||||||
|
id: req.uid,
|
||||||
|
actor: req.params.uid,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
await api.users.follow(req, req.params);
|
await api.users.follow(req, req.params);
|
||||||
|
}
|
||||||
|
|
||||||
helpers.formatApiResponse(200, res);
|
helpers.formatApiResponse(200, res);
|
||||||
};
|
};
|
||||||
|
|
||||||
Users.unfollow = async (req, res) => {
|
Users.unfollow = async (req, res) => {
|
||||||
|
const remote = String(req.params.uid).includes('@');
|
||||||
|
if (remote) {
|
||||||
|
await api.activitypub.unfollow(req, {
|
||||||
|
type: 'uid',
|
||||||
|
id: req.uid,
|
||||||
|
actor: req.params.uid,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
await api.users.unfollow(req, req.params);
|
await api.users.unfollow(req, req.params);
|
||||||
|
}
|
||||||
helpers.formatApiResponse(200, res);
|
helpers.formatApiResponse(200, res);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -11,11 +11,14 @@ if (!databaseName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const primaryDB = require(`./${databaseName}`);
|
const primaryDB = require(`./${databaseName}`);
|
||||||
|
const utils = require('../utils');
|
||||||
|
|
||||||
primaryDB.parseIntFields = function (data, intFields, requestedFields) {
|
primaryDB.parseIntFields = function (data, intFields, requestedFields) {
|
||||||
intFields.forEach((field) => {
|
intFields.forEach((field) => {
|
||||||
if (!requestedFields || !requestedFields.length || requestedFields.includes(field)) {
|
if (!requestedFields || !requestedFields.length || requestedFields.includes(field)) {
|
||||||
data[field] = parseInt(data[field], 10) || 0;
|
data[field] = utils.isNumber(data[field]) ?
|
||||||
|
parseInt(data[field], 10) :
|
||||||
|
data[field] || 0;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
42
src/flags.js
42
src/flags.js
@@ -4,6 +4,8 @@ const _ = require('lodash');
|
|||||||
const winston = require('winston');
|
const winston = require('winston');
|
||||||
const validator = require('validator');
|
const validator = require('validator');
|
||||||
|
|
||||||
|
const activitypub = require('./activitypub');
|
||||||
|
const activitypubApi = require('./api/activitypub');
|
||||||
const db = require('./database');
|
const db = require('./database');
|
||||||
const user = require('./user');
|
const user = require('./user');
|
||||||
const groups = require('./groups');
|
const groups = require('./groups');
|
||||||
@@ -352,7 +354,8 @@ Flags.getFlagIdByTarget = async function (type, id) {
|
|||||||
throw new Error('[[error:invalid-data]]');
|
throw new Error('[[error:invalid-data]]');
|
||||||
}
|
}
|
||||||
|
|
||||||
return await method(id, 'flagId');
|
const flagId = await method(id, 'flagId');
|
||||||
|
return utils.isNumber(flagId) ? parseInt(flagId, 10) : flagId;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function modifyNotes(notes) {
|
async function modifyNotes(notes) {
|
||||||
@@ -389,7 +392,7 @@ Flags.deleteNote = async function (flagId, datetime) {
|
|||||||
await db.sortedSetRemove(`flag:${flagId}:notes`, note[0]);
|
await db.sortedSetRemove(`flag:${flagId}:notes`, note[0]);
|
||||||
};
|
};
|
||||||
|
|
||||||
Flags.create = async function (type, id, uid, reason, timestamp, forceFlag = false) {
|
Flags.create = async function (type, id, uid, reason, timestamp, forceFlag = false, notifyRemote = false) {
|
||||||
let doHistoryAppend = false;
|
let doHistoryAppend = false;
|
||||||
if (!timestamp) {
|
if (!timestamp) {
|
||||||
timestamp = Date.now();
|
timestamp = Date.now();
|
||||||
@@ -416,7 +419,7 @@ Flags.create = async function (type, id, uid, reason, timestamp, forceFlag = fal
|
|||||||
if (targetFlagged) {
|
if (targetFlagged) {
|
||||||
const flagId = await Flags.getFlagIdByTarget(type, id);
|
const flagId = await Flags.getFlagIdByTarget(type, id);
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
Flags.addReport(flagId, type, id, uid, reason, timestamp),
|
Flags.addReport(flagId, type, id, uid, reason, timestamp, targetUid, notifyRemote),
|
||||||
Flags.update(flagId, uid, {
|
Flags.update(flagId, uid, {
|
||||||
state: 'open',
|
state: 'open',
|
||||||
report: 'added',
|
report: 'added',
|
||||||
@@ -437,7 +440,7 @@ Flags.create = async function (type, id, uid, reason, timestamp, forceFlag = fal
|
|||||||
targetUid: targetUid,
|
targetUid: targetUid,
|
||||||
datetime: timestamp,
|
datetime: timestamp,
|
||||||
}),
|
}),
|
||||||
Flags.addReport(flagId, type, id, uid, reason, timestamp),
|
Flags.addReport(flagId, type, id, uid, reason, timestamp, targetUid, notifyRemote),
|
||||||
db.sortedSetAdd('flags:datetime', timestamp, flagId), // by time, the default
|
db.sortedSetAdd('flags:datetime', timestamp, flagId), // by time, the default
|
||||||
db.sortedSetAdd(`flags:byType:${type}`, timestamp, flagId), // by flag type
|
db.sortedSetAdd(`flags:byType:${type}`, timestamp, flagId), // by flag type
|
||||||
db.sortedSetIncrBy('flags:byTarget', 1, [type, id].join(':')), // by flag target (score is count)
|
db.sortedSetIncrBy('flags:byTarget', 1, [type, id].join(':')), // by flag target (score is count)
|
||||||
@@ -474,6 +477,11 @@ Flags.create = async function (type, id, uid, reason, timestamp, forceFlag = fal
|
|||||||
|
|
||||||
const flagObj = await Flags.get(flagId);
|
const flagObj = await Flags.get(flagId);
|
||||||
|
|
||||||
|
if (notifyRemote && activitypub.helpers.isUri(id)) {
|
||||||
|
const caller = await user.getUserData(uid);
|
||||||
|
activitypubApi.flag(caller, { ...flagObj, reason });
|
||||||
|
}
|
||||||
|
|
||||||
plugins.hooks.fire('action:flags.create', { flag: flagObj });
|
plugins.hooks.fire('action:flags.create', { flag: flagObj });
|
||||||
return flagObj;
|
return flagObj;
|
||||||
};
|
};
|
||||||
@@ -521,6 +529,13 @@ Flags.purge = async function (flagIds) {
|
|||||||
'flags:byTarget',
|
'flags:byTarget',
|
||||||
flagData.map(flagObj => [flagObj.type, flagObj.targetId].join(':'))
|
flagData.map(flagObj => [flagObj.type, flagObj.targetId].join(':'))
|
||||||
),
|
),
|
||||||
|
flagData.flatMap(
|
||||||
|
async (flagObj, i) => allReporterUids[i].map(async (uid) => {
|
||||||
|
if (await db.isSortedSetMember(`flag:${flagObj.flagId}:remote`, uid)) {
|
||||||
|
await activitypubApi.undo.flag({ uid }, flagObj);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -546,7 +561,7 @@ Flags.getReports = async function (flagId) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Not meant to be called directly, call Flags.create() instead.
|
// Not meant to be called directly, call Flags.create() instead.
|
||||||
Flags.addReport = async function (flagId, type, id, uid, reason, timestamp) {
|
Flags.addReport = async function (flagId, type, id, uid, reason, timestamp, targetUid, notifyRemote) {
|
||||||
await db.sortedSetAddBulk([
|
await db.sortedSetAddBulk([
|
||||||
[`flags:byReporter:${uid}`, timestamp, flagId],
|
[`flags:byReporter:${uid}`, timestamp, flagId],
|
||||||
[`flag:${flagId}:reports`, timestamp, [uid, reason].join(';')],
|
[`flag:${flagId}:reports`, timestamp, [uid, reason].join(';')],
|
||||||
@@ -554,6 +569,10 @@ Flags.addReport = async function (flagId, type, id, uid, reason, timestamp) {
|
|||||||
['flags:hash', flagId, [type, id, uid].join(':')],
|
['flags:hash', flagId, [type, id, uid].join(':')],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if (notifyRemote && activitypub.helpers.isUri(id)) {
|
||||||
|
await activitypubApi.flag({ uid }, { flagId, type, targetId: id, targetUid, uid, reason, timestamp });
|
||||||
|
}
|
||||||
|
|
||||||
plugins.hooks.fire('action:flags.addReport', { flagId, type, id, uid, reason, timestamp });
|
plugins.hooks.fire('action:flags.addReport', { flagId, type, id, uid, reason, timestamp });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -579,6 +598,11 @@ Flags.rescindReport = async (type, id, uid) => {
|
|||||||
throw new Error('[[error:cant-locate-flag-report]]');
|
throw new Error('[[error:cant-locate-flag-report]]');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (await db.isSortedSetMember(`flag:${flagId}:remote`, uid)) {
|
||||||
|
const flag = await Flags.get(flagId);
|
||||||
|
await activitypubApi.undo.flag({ uid }, flag);
|
||||||
|
}
|
||||||
|
|
||||||
await db.sortedSetRemoveBulk([
|
await db.sortedSetRemoveBulk([
|
||||||
[`flags:byReporter:${uid}`, flagId],
|
[`flags:byReporter:${uid}`, flagId],
|
||||||
[`flag:${flagId}:reports`, [uid, reason].join(';')],
|
[`flag:${flagId}:reports`, [uid, reason].join(';')],
|
||||||
@@ -681,6 +705,14 @@ Flags.targetExists = async function (type, id) {
|
|||||||
if (type === 'post') {
|
if (type === 'post') {
|
||||||
return await posts.exists(id);
|
return await posts.exists(id);
|
||||||
} else if (type === 'user') {
|
} else if (type === 'user') {
|
||||||
|
if (activitypub.helpers.isUri(id)) {
|
||||||
|
try {
|
||||||
|
const actor = await activitypub.get('uid', 0, id);
|
||||||
|
return !!actor;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
return await user.exists(id);
|
return await user.exists(id);
|
||||||
}
|
}
|
||||||
throw new Error('[[error:invalid-data]]');
|
throw new Error('[[error:invalid-data]]');
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ module.exports = function (Groups) {
|
|||||||
Groups.validateGroupName(data.name);
|
Groups.validateGroupName(data.name);
|
||||||
|
|
||||||
const [exists, privGroupExists] = await Promise.all([
|
const [exists, privGroupExists] = await Promise.all([
|
||||||
meta.userOrGroupExists(data.name),
|
meta.slugTaken(data.name),
|
||||||
privilegeGroupExists(data.name),
|
privilegeGroupExists(data.name),
|
||||||
]);
|
]);
|
||||||
if (exists || privGroupExists) {
|
if (exists || privGroupExists) {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ require('./cache')(Groups);
|
|||||||
|
|
||||||
Groups.BANNED_USERS = 'banned-users';
|
Groups.BANNED_USERS = 'banned-users';
|
||||||
|
|
||||||
Groups.ephemeralGroups = ['guests', 'spiders'];
|
Groups.ephemeralGroups = ['guests', 'spiders', 'fediverse'];
|
||||||
|
|
||||||
Groups.systemGroups = [
|
Groups.systemGroups = [
|
||||||
'registered-users',
|
'registered-users',
|
||||||
|
|||||||
@@ -436,6 +436,37 @@ async function giveGlobalPrivileges() {
|
|||||||
]), 'Global Moderators');
|
]), 'Global Moderators');
|
||||||
await privileges.global.give(['groups:view:users', 'groups:view:tags', 'groups:view:groups'], 'guests');
|
await privileges.global.give(['groups:view:users', 'groups:view:tags', 'groups:view:groups'], 'guests');
|
||||||
await privileges.global.give(['groups:view:users', 'groups:view:tags', 'groups:view:groups'], 'spiders');
|
await privileges.global.give(['groups:view:users', 'groups:view:tags', 'groups:view:groups'], 'spiders');
|
||||||
|
await privileges.global.give(['groups:view:users'], 'fediverse');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function giveWorldPrivileges() {
|
||||||
|
// should match privilege assignment logic in src/categories/create.js EXCEPT commented one liner below
|
||||||
|
const privileges = require('./privileges');
|
||||||
|
const defaultPrivileges = [
|
||||||
|
'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',
|
||||||
|
];
|
||||||
|
const modPrivileges = defaultPrivileges.concat([
|
||||||
|
'groups:topics:schedule',
|
||||||
|
'groups:posts:view_deleted',
|
||||||
|
'groups:purge',
|
||||||
|
]);
|
||||||
|
const guestPrivileges = ['groups:find', 'groups:read', 'groups:topics:read'];
|
||||||
|
|
||||||
|
await privileges.categories.give(defaultPrivileges, -1, ['registered-users']);
|
||||||
|
await privileges.categories.give(defaultPrivileges.slice(2), -1, ['fediverse']); // different priv set for fediverse
|
||||||
|
await privileges.categories.give(modPrivileges, -1, ['administrators', 'Global Moderators']);
|
||||||
|
await privileges.categories.give(guestPrivileges, -1, ['guests', 'spiders']);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createCategories() {
|
async function createCategories() {
|
||||||
@@ -588,6 +619,7 @@ install.setup = async function () {
|
|||||||
const adminInfo = await createAdministrator();
|
const adminInfo = await createAdministrator();
|
||||||
await createGlobalModeratorsGroup();
|
await createGlobalModeratorsGroup();
|
||||||
await giveGlobalPrivileges();
|
await giveGlobalPrivileges();
|
||||||
|
await giveWorldPrivileges();
|
||||||
await createMenuItems();
|
await createMenuItems();
|
||||||
await createWelcomePost();
|
await createWelcomePost();
|
||||||
await enableDefaultPlugins();
|
await enableDefaultPlugins();
|
||||||
@@ -630,3 +662,5 @@ install.save = async function (server_conf) {
|
|||||||
file: serverConfigPath,
|
file: serverConfigPath,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
install.giveWorldPrivileges = giveWorldPrivileges; // exported for upgrade script and test runner
|
||||||
|
|||||||
0
src/messaging/uploads.js
Normal file
0
src/messaging/uploads.js
Normal file
@@ -26,9 +26,9 @@ Meta.languages = require('./languages');
|
|||||||
|
|
||||||
const user = require('../user');
|
const user = require('../user');
|
||||||
const groups = require('../groups');
|
const groups = require('../groups');
|
||||||
|
const categories = require('../categories');
|
||||||
|
|
||||||
/* Assorted */
|
Meta.slugTaken = async function (slug) {
|
||||||
Meta.userOrGroupExists = async function (slug) {
|
|
||||||
const isArray = Array.isArray(slug);
|
const isArray = Array.isArray(slug);
|
||||||
if ((isArray && slug.some(slug => !slug)) || (!isArray && !slug)) {
|
if ((isArray && slug.some(slug => !slug)) || (!isArray && !slug)) {
|
||||||
throw new Error('[[error:invalid-data]]');
|
throw new Error('[[error:invalid-data]]');
|
||||||
@@ -36,16 +36,19 @@ Meta.userOrGroupExists = async function (slug) {
|
|||||||
|
|
||||||
slug = isArray ? slug.map(s => slugify(s, false)) : slugify(slug);
|
slug = isArray ? slug.map(s => slugify(s, false)) : slugify(slug);
|
||||||
|
|
||||||
const [userExists, groupExists] = await Promise.all([
|
const [userExists, groupExists, categoryExists] = await Promise.all([
|
||||||
user.existsBySlug(slug),
|
user.existsBySlug(slug),
|
||||||
groups.existsBySlug(slug),
|
groups.existsBySlug(slug),
|
||||||
|
categories.existsByHandle(slug),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return isArray ?
|
return isArray ?
|
||||||
slug.map((s, i) => userExists[i] || groupExists[i]) :
|
slug.map((s, i) => userExists[i] || groupExists[i] || categoryExists[i]) :
|
||||||
(userExists || groupExists);
|
(userExists || groupExists || categoryExists);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Meta.userOrGroupExists = Meta.slugTaken; // backwards compatiblity
|
||||||
|
|
||||||
if (nconf.get('isPrimary')) {
|
if (nconf.get('isPrimary')) {
|
||||||
pubsub.on('meta:restart', (data) => {
|
pubsub.on('meta:restart', (data) => {
|
||||||
if (data.hostname !== os.hostname()) {
|
if (data.hostname !== os.hostname()) {
|
||||||
|
|||||||
127
src/middleware/activitypub.js
Normal file
127
src/middleware/activitypub.js
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const db = require('../database');
|
||||||
|
const meta = require('../meta');
|
||||||
|
const activitypub = require('../activitypub');
|
||||||
|
|
||||||
|
const middleware = module.exports;
|
||||||
|
|
||||||
|
middleware.enabled = async (req, res, next) => next(!meta.config.activitypubEnabled ? 'route' : undefined);
|
||||||
|
|
||||||
|
middleware.assertS2S = async function (req, res, next) {
|
||||||
|
// For whatever reason, express accepts does not recognize "profile" as a valid differentiator
|
||||||
|
// Therefore, manual header parsing is used here.
|
||||||
|
const { accept, 'content-type': contentType } = req.headers;
|
||||||
|
if (!(accept || contentType)) {
|
||||||
|
return next('route');
|
||||||
|
}
|
||||||
|
|
||||||
|
const pass = (accept && accept.split(',').some((value) => {
|
||||||
|
const parts = value.split(';').map(v => v.trim());
|
||||||
|
return activitypub._constants.acceptableTypes.includes(value || parts[0]);
|
||||||
|
})) || (contentType && activitypub._constants.acceptableTypes.includes(contentType));
|
||||||
|
|
||||||
|
if (!pass) {
|
||||||
|
return next('route');
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
middleware.validate = async function (req, res, next) {
|
||||||
|
// winston.verbose('[middleware/activitypub] Validating incoming payload...');
|
||||||
|
|
||||||
|
// Sanity-check payload schema
|
||||||
|
const required = ['id', 'type', 'actor', 'object'];
|
||||||
|
if (!required.every(prop => req.body.hasOwnProperty(prop))) {
|
||||||
|
// winston.verbose('[middleware/activitypub] Request body missing required properties.');
|
||||||
|
return res.sendStatus(400);
|
||||||
|
}
|
||||||
|
// winston.verbose('[middleware/activitypub] Request body check passed.');
|
||||||
|
|
||||||
|
// History check
|
||||||
|
/*
|
||||||
|
const seen = await db.isSortedSetMember('activities:datetime', req.body.id);
|
||||||
|
if (seen) {
|
||||||
|
// winston.verbose(`[middleware/activitypub] Activity already seen, ignoring (${req.body.id}).`);
|
||||||
|
return res.sendStatus(200);
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Checks the validity of the incoming payload against the sender and rejects on failure
|
||||||
|
const verified = await activitypub.verify(req);
|
||||||
|
if (!verified) {
|
||||||
|
// winston.verbose('[middleware/activitypub] HTTP signature verification failed.');
|
||||||
|
return res.sendStatus(400);
|
||||||
|
}
|
||||||
|
// winston.verbose('[middleware/activitypub] HTTP signature verification passed.');
|
||||||
|
|
||||||
|
let { actor, object } = req.body;
|
||||||
|
|
||||||
|
// Actor normalization
|
||||||
|
if (typeof actor === 'object' && actor.hasOwnProperty('id')) {
|
||||||
|
actor = actor.id;
|
||||||
|
req.body.actor = actor;
|
||||||
|
}
|
||||||
|
if (Array.isArray(actor)) {
|
||||||
|
actor = actor.map(a => (typeof a === 'string' ? a : a.id));
|
||||||
|
req.body.actor = actor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Domain check
|
||||||
|
const { hostname } = new URL(actor);
|
||||||
|
const allowed = await activitypub.instances.isAllowed(hostname);
|
||||||
|
if (!allowed) {
|
||||||
|
// winston.verbose(`[middleware/activitypub] Blocked incoming activity from ${hostname}.`);
|
||||||
|
return res.sendStatus(403);
|
||||||
|
}
|
||||||
|
await db.sortedSetAdd('instances:lastSeen', Date.now(), hostname);
|
||||||
|
|
||||||
|
// Origin checking
|
||||||
|
if (typeof object !== 'string' && object.hasOwnProperty('id')) {
|
||||||
|
const actorHostnames = Array.isArray(actor) ? actor.map(a => new URL(a).hostname) : [new URL(actor).hostname];
|
||||||
|
const objectHostname = new URL(object.id).hostname;
|
||||||
|
// require that all actors have the same hostname as the object for now
|
||||||
|
if (!actorHostnames.every(actorHostname => actorHostname === objectHostname)) {
|
||||||
|
// winston.verbose('[middleware/activitypub] Origin check failed, stripping object down to id.');
|
||||||
|
req.body.object = [object.id];
|
||||||
|
}
|
||||||
|
// winston.verbose('[middleware/activitypub] Origin check passed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cross-check key ownership against received actor
|
||||||
|
await activitypub.actors.assert(actor);
|
||||||
|
const compare = await db.getObjectField(`userRemote:${actor}:keys`, 'id');
|
||||||
|
const { signature } = req.headers;
|
||||||
|
const keyId = new Map(signature.split(',').filter(Boolean).map((v) => {
|
||||||
|
const index = v.indexOf('=');
|
||||||
|
return [v.substring(0, index), v.slice(index + 1)];
|
||||||
|
})).get('keyId');
|
||||||
|
if (`"${compare}"` !== keyId) {
|
||||||
|
// winston.verbose('[middleware/activitypub] Key ownership cross-check failed.');
|
||||||
|
return res.sendStatus(403);
|
||||||
|
}
|
||||||
|
// winston.verbose('[middleware/activitypub] Key ownership cross-check passed.');
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
middleware.resolveObjects = async function (req, res, next) {
|
||||||
|
const { type, object } = req.body;
|
||||||
|
if (type !== 'Delete' && (typeof object === 'string' || (Array.isArray(object) && object.every(o => typeof o === 'string')))) {
|
||||||
|
// winston.verbose('[middleware/activitypub] Resolving object(s)...');
|
||||||
|
try {
|
||||||
|
req.body.object = await activitypub.helpers.resolveObjects(object);
|
||||||
|
// winston.verbose('[middleware/activitypub] Object(s) successfully resolved.');
|
||||||
|
} catch (e) {
|
||||||
|
// winston.verbose('[middleware/activitypub] Failed to resolve object(s).');
|
||||||
|
return res.sendStatus(424);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
middleware.configureResponse = async function (req, res, next) {
|
||||||
|
res.header('Content-Type', 'application/activity+json');
|
||||||
|
next();
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user