Compare commits

...

814 Commits

Author SHA1 Message Date
Julian Lam
86f624f817 chore: beta tag 2024-09-20 14:07:44 -04:00
Julian Lam
34c4cefe0c feat: enable web-push plugin on first install 2024-09-20 14:04:45 -04:00
Julian Lam
aa060d31d1 fix(deps): swap ntfy for web-push plugin 2024-09-20 14:03:51 -04:00
Julian Lam
ad6d03266b Merge remote-tracking branch 'origin/develop' into activitypub 2024-09-20 14:03:16 -04:00
Julian Lam
4ae8614573 feat: service worker icon and badge support 2024-09-20 11:19:50 -04:00
Julian Lam
6b4b4b1a00 fix: actors.assert attempting to process things that aren't actors, #12809 2024-09-19 14:52:05 -04:00
Julian Lam
6752a54116 feat: update buildRecipients to include all topic participants and their followers, #12735 2024-09-18 14:35:42 -04:00
Julian Lam
71ce308936 fix: carry out mime-type guessing on incoming attachments 2024-09-17 14:18:16 -04:00
Julian Lam
d0ac5ff99f Reapply "fix: change setImmediate to a 5s timeout to give plugins (or anything waiting for the return value of the API call) a chance to finish execution"
This reverts commit 8003946feb.
2024-09-17 11:58:46 -04:00
Julian Lam
b04a11055f fix: re-use already parsed html in api.posts.edit's call to getPostSummaryByPids, delay federating out edit activity for 5s to give link preview a chance to resolve 2024-09-17 11:58:42 -04:00
Julian Lam
047d5992dc fix: id on 1b12 announces 2024-09-17 10:46:05 -04:00
Barış Soner Uşaklı
1fe8ac191d change follow notif path 2024-09-16 19:42:27 -04:00
Julian Lam
3c55d1024d fix: better handling for remote posts that do not contain content 2024-09-16 15:46:12 -04:00
Julian Lam
1a0a2cd17b fix: #12799 add externally linked images to attachment 2024-09-16 15:07:37 -04:00
Barış Soner Uşaklı
d7ae6a5b9b Merge branch 'develop' into activitypub 2024-09-13 12:47:14 -04:00
Julian Lam
d6bab25317 feat: notification rescind support 2024-09-12 22:14:24 -04:00
Julian Lam
028b6d74b2 fix: remove includeUncontrolled as we are posting messages, and that only works with windows you control lol 2024-09-12 16:12:24 -04:00
Barış Soner Uşaklı
471fbd3ab4 chore: up themes 2024-09-12 14:21:07 -04:00
Barış Soner Uşaklı
42edde63ca Merge branch 'develop' into activitypub 2024-09-12 14:06:45 -04:00
Julian Lam
1fbf911489 fix: add logic to automatically take over clients from old service workers 2024-09-12 11:22:02 -04:00
Barış Soner Uşaklı
d4cf5e7ee0 chore: up themes 2024-09-12 10:38:33 -04:00
Barış Soner Uşaklı
ad565a98ea Merge branch 'develop' into activitypub 2024-09-12 10:32:40 -04:00
Julian Lam
cf6c4c52b8 refactor: simplified ajaxify.check and likely made it equally more confusing 2024-09-11 14:43:55 -04:00
Barış Soner Uşaklı
c2600b124f fix: closes #12787, filter null topics 2024-09-11 14:26:16 -04:00
Julian Lam
5109b53921 feat: update service worker to handle push and notificationclick
Additional event handling for use by the upcoming web-push plugin.
2024-09-11 12:18:31 -04:00
Julian Lam
40fe4d88f9 feat: add onmessage handler for service worker 2024-09-11 12:15:12 -04:00
Julian Lam
fc4f6a4c56 refactor: split out logic to determine ajaxification into exported method
There was some internal logic in ajaxify that handled special links that
should explicitly not be ajaxified (either it is a null href or should be
loaded as a direct page load, etc.) - this was moved out to an exported
method so it can be consumed by the service worker onmessage listener.

Also since this logic evolved over many years, there were some
duplications and so those have been amended (though I will not guarantee
that it was done bug/regression free!!)
2024-09-11 12:14:04 -04:00
Julian Lam
176f4d0d09 fix: remove use of jquery $(this) in ajaxify 2024-09-11 10:22:34 -04:00
Julian Lam
d42d3b1c39 fix: crash when AP S2S call made to retrieve a remote user account (not allowed); now returning 404 2024-09-10 11:37:56 -04:00
Barış Soner Uşaklı
97edcf6a48 Merge branch 'develop' into activitypub 2024-09-09 17:31:41 -04:00
Barış Soner Uşaklı
a80e314169 fix: prevent crash if items is undefined 2024-09-09 14:04:11 -04:00
Barış Soner Uşaklı
6010b72d71 Merge branch 'develop' into activitypub 2024-09-09 14:03:25 -04:00
Julian Lam
652d6c6e2b fix: regression that caused replies to not get federated 2024-09-06 22:04:20 -04:00
Julian Lam
045e16d3ce fix: #12760, add proper security context for application actor 2024-09-06 16:27:12 -04:00
Julian Lam
909437c306 fix: on topic move out of cid -1, also federate to the public address 2024-09-06 14:35:57 -04:00
Julian Lam
6ed32f6781 fix: bump harmony 2024-09-06 14:16:20 -04:00
Julian Lam
0f63f67b77 fix: type casting in user block logic 2024-09-05 12:41:25 -04:00
Julian Lam
9357e71898 refactor: standardization; onNewPost internal method to return a superset of post summary
... so as to not require an additional call in internal topics API to call post summary again for a standardized response
2024-09-05 11:42:05 -04:00
Julian Lam
b63440cb14 feat: extend activitypubApi.create.note to accept a post object to reduce unnecessary calls to retrieve post summary 2024-09-05 11:05:30 -04:00
Julian Lam
16c8a6523a feat: add new escape option to getPostSummaryByPids [breaking]
Changes logic so that the new `escape` option escapes HTML,
whereas the old behaviour had `parse: false` escape HTML.

Now, when `parse` is `false`, the content is unchanged.

Defaults are `{ parse: true, escape: false, stripTags: false }`
2024-09-05 10:42:08 -04:00
Julian Lam
ba2c3fcef4 Merge remote-tracking branch 'origin/develop' into activitypub 2024-09-04 14:38:50 -04:00
Julian Lam
f3eb6d500e fix: restrict announce activity to topic moving OUT of cid -1 2024-08-22 15:19:24 -04:00
Julian Lam
e3bfcb0230 Merge branch 'develop' into activitypub 2024-08-22 15:06:53 -04:00
Julian Lam
daa665ebc0 feat: moving a topic out of cid -1 federates an Announce activity, #12734 2024-08-22 14:45:17 -04:00
Julian Lam
76551c7123 fix: #12751, topics:recent zset not updated when tid moved in/out of cid -1 2024-08-22 11:17:27 -04:00
Barış Soner Uşaklı
df6062d8be Merge branch 'develop' into activitypub 2024-08-21 12:57:10 -04:00
Julian Lam
e6d8e05932 fix: bump mentions 2024-08-15 12:56:20 -04:00
Julian Lam
22952c5407 fix: bump composer 2024-08-14 14:03:11 -04:00
Julian Lam
e287956323 fix: bump composer 2024-08-13 15:30:36 -04:00
Julian Lam
c3e14912c9 feat: additional copy re: editing remote posts, #12732 2024-08-13 15:29:03 -04:00
Julian Lam
25bf97ae28 fix: #12732 editing of remote post content
title can now be edited, post content is not-editable.
2024-08-13 15:12:57 -04:00
Julian Lam
3474712228 fix: #12733, html present in generated title for remote topics 2024-08-13 14:50:08 -04:00
Julian Lam
f481cde1a8 fix: #12729, replies to existing topics from Pixelfed not asserting properly due to incorrect toPid
Pixelfed supplies an object _url_ instead of the expected _id_ in the `inReplyTo` field, and that tripped up NodeBB because we don't store a backreference for those.

The ideal solution here would be to set up a backreference for urls to pids, but in the meantime, this shortcut will function (it assumes that the object that it is in reply to is in the chain/context).
2024-08-13 11:12:48 -04:00
Julian Lam
95e6d2b43c fix: move topic reply call in notes.assert to its own line 2024-08-13 10:52:27 -04:00
Julian Lam
53aee40149 fix: #12510, blocking of a single remote user causes content from all remote users to be blocked 2024-08-12 14:50:55 -04:00
Julian Lam
fc81d6e035 Merge branch 'develop' into activitypub 2024-08-08 14:54:34 -04:00
Julian Lam
bd11d86b17 Merge branch 'develop' into activitypub 2024-08-07 14:19:00 -04:00
Julian Lam
cbe0a0ad5c fix: serve category actor image instead of nothing, serve brand logo in icon 2024-08-06 15:06:01 -04:00
Julian Lam
d33fb92d9d fix: lint 2024-08-02 15:47:27 -04:00
Julian Lam
9353638668 fix: have note federation call .parsePost instead of firing plugin hook 2024-08-02 15:47:03 -04:00
Julian Lam
709a02d97a feat: allow use of vanity domains pointing to an account hosted elsewhere 2024-08-02 11:47:23 -04:00
Julian Lam
ff08fbb73f fix: always send replies uri instead of null if no replies exist, @trwnh 2024-08-01 14:59:28 -04:00
Julian Lam
9bd6896d13 fix: send null instead of empty array in replies property if mocked note has no replies
re: mastodon/mastodon#31230
2024-08-01 11:35:38 -04:00
Julian Lam
e015339ccf refactor: follow/accept/undo logic to pass the same timestamp throughout the follow's lifetime 2024-07-30 14:05:59 -04:00
Julian Lam
2ced350250 fix: bug that caused remote replies to other remote content to not always assert 2024-07-29 16:03:58 -04:00
Julian Lam
1f896fb6ba fix: bugs in user searching causing remote lookups to fail 2024-07-29 15:03:51 -04:00
Julian Lam
9a54f6bc7d fix: audience property in topic actor response 2024-07-29 13:14:06 -04:00
Julian Lam
3e494dc757 fix: updating of post counts for remote users 2024-07-25 14:30:57 -04:00
Barış Soner Uşaklı
8971bba53d Merge branch 'develop' into activitypub 2024-07-25 11:26:29 -04:00
Julian Lam
399d41030f fix: add privilege check to user follows 2024-07-24 11:59:50 -04:00
Julian Lam
7bf349b62b fix: accidental passing of string instead of constant 2024-07-24 11:54:41 -04:00
Julian Lam
83993cad17 fix(deps): persona v4 support 2024-07-23 11:11:31 -04:00
Julian Lam
fc5a829c0c fix: lint 2024-07-19 15:15:04 -04:00
Julian Lam
b9f37c5c32 fix: update upgrade script date 2024-07-19 14:57:56 -04:00
Julian Lam
be393d571c fix: include follower sets for reassertion 2024-07-19 14:48:51 -04:00
Julian Lam
39f92eac16 refactor: store local follow backreferences for remote users (both followers and following), update actor pruning to take local follow counts into consideration, fixes #12701 2024-07-19 14:37:32 -04:00
Julian Lam
a884681abf feat: show local users who follow (or are followed) by a remote user, on a remote user's profile 2024-07-19 14:36:17 -04:00
Julian Lam
5c9ac5d330 fix: missing await 2024-07-19 11:13:30 -04:00
Julian Lam
f9937a84c4 feat: additional logic to handle special case where a queried object id reports a context, but it is not actually contained in the resolved context's collection 2024-07-19 11:06:19 -04:00
Julian Lam
2aa0483718 fix: null check for note attachments for thumbs view 2024-07-19 10:51:41 -04:00
Julian Lam
60408f2307 fix: incorrect parsing of context items during note assertion, logic error with slicing 2024-07-19 10:50:14 -04:00
Julian Lam
bc00835c26 fix: logging in topic actor logic 2024-07-19 10:49:43 -04:00
Julian Lam
609035b587 fix: issue with topic actors not returning proper json 2024-07-19 09:38:30 -04:00
Julian Lam
ff0c289e1d feat: #12695 Topic Synchronization via resolvable context
- Generation of a context collection digest via object ids
- Sending of said digest in ETag header
- Parsing of digests via If-None-Match header
- Update note assertion logic to handle 304 response
2024-07-16 11:37:38 -04:00
Julian Lam
da25fd21ea feat: activitypub.get; allow the passing-in of custom headers via existing options parameter 2024-07-16 11:36:39 -04:00
Barış Soner Uşaklı
26765fe9aa lint: remove unused function 2024-07-15 12:05:55 -04:00
Barış Soner Uşaklı
2a8f7ccd1c Merge branch 'develop' into activitypub 2024-07-15 11:54:31 -04:00
Julian Lam
cfbaf145f4 fix: off by one error @barisusakli 2024-07-12 14:45:00 -04:00
Julian Lam
f6f01ba345 perf: ~18x speedup of /world route
- upgrade script to remove pruned tids that did not get removed from inbox
- switch from db intersect to manual intersection of subset.
2024-07-12 14:38:46 -04:00
Julian Lam
c01f154937 breaking: core html stripping on plaintext, remove 'filter:teasers.configureStripTags' hook, remove html stripping from teaser generation
These two steps were removed because a `type` property is passed to `filter:post.parse` now, and thus plugins themselves can handle independently.

Core also now strips all html when `type` is plaintext, so plugins don't have to (or if they still pass html back).
2024-07-12 12:30:37 -04:00
Julian Lam
7bdf376971 fix: map to actual mainPid value 2024-07-12 10:58:37 -04:00
Barış Soner Uşaklı
530241e0f2 lint: remove unused 2024-07-11 22:27:41 -04:00
Barış Soner Uşaklı
9b8c834ef0 perf: get rid of exists call, load all attachments in one call 2024-07-11 22:24:32 -04:00
Julian Lam
2aeabce566 feat: add post attachments to topic thumbnails 2024-07-11 17:11:33 -04:00
Julian Lam
9219199591 fix: skip topic retrieval when cid is -1, as it is done again later 2024-07-11 15:27:26 -04:00
Julian Lam
31c7226e34 fix: flawed logic in generating topic context collection 2024-07-11 14:33:07 -04:00
Julian Lam
3c0d7616db fix: rename announcers to shares 2024-07-11 13:42:53 -04:00
Julian Lam
ad05f06b00 test: have actor assertion always use cache for tests 2024-07-11 13:16:53 -04:00
Julian Lam
a81ef60930 feat: icon replacing user status for remote users, denoting that they are from outside the local instance, #12688 2024-07-11 13:16:47 -04:00
Julian Lam
fdc0d670d7 fix: catch thrown errors in src/api/activitypub 2024-07-10 10:15:31 -04:00
Julian Lam
00a266790d fix: federating category mentions 2024-07-09 15:42:08 -04:00
Julian Lam
f605e188db fix: send html in category description 2024-07-09 15:13:12 -04:00
Julian Lam
485b775837 feat: allow activitypub request cache to be ignored 2024-07-09 14:45:04 -04:00
Julian Lam
5e343a83f8 fix: Update activity handling for non-Person actors 2024-07-09 14:30:03 -04:00
Julian Lam
bacbfba9bd fix: remove await from category update federation call 2024-07-09 14:24:55 -04:00
Julian Lam
3c9337b730 feat: send join date in local actor mock 2024-07-09 14:06:59 -04:00
Julian Lam
e84cfd8c07 fix: issue where incrementing user hash fields didn't use the right key name for remote announce @barisusakli 2024-07-09 14:01:57 -04:00
Julian Lam
b0eec67d78 fix: lint 2024-07-05 15:58:47 -04:00
Julian Lam
af6ae6ee8a fix: send back empty array if no direct replies, bug with reply generation 2024-07-05 15:50:14 -04:00
Julian Lam
26a7c51a9a feat: AP note replies collection, closes #12675 2024-07-05 15:33:06 -04:00
Julian Lam
352857cfb6 refactor: collection logic out to an activitypub helper 2024-07-05 15:14:35 -04:00
Julian Lam
8e1fccf014 fix: accidental passing-in of an array into getUserField, breaks in pgsql? 2024-07-05 14:19:15 -04:00
Julian Lam
78a6c60cf5 fix: hardcoded tid in topic thumbs test 2024-07-05 14:01:58 -04:00
Julian Lam
f629b20f2d fix: tighten up logic in checkToPid internal method 2024-07-05 13:49:16 -04:00
Julian Lam
3a05171f3c docs: schema update for activitypub acp route 2024-07-05 13:36:47 -04:00
Julian Lam
4949d6c4e5 test: fix broken test in category follows 2024-07-05 13:33:00 -04:00
Julian Lam
8ecbb5cc5c fix: partial revert to fix url_parsed not being available at top of file @barisusakli 2024-07-05 12:14:16 -04:00
Julian Lam
46341673b3 fix: removal of topics from applicable user inboxes when synced
re: #12642
2024-07-05 12:00:23 -04:00
Julian Lam
0d549f1d80 fix: tweak logic for topic counters sorted sets, #12642 2024-07-05 11:41:47 -04:00
Julian Lam
d92efb8a0c fix: #12667; handle cases where received id is not a URL 2024-07-05 11:29:05 -04:00
Julian Lam
f662a60667 fix: #12673 fullname not defined in some remote actors 2024-07-05 11:18:20 -04:00
Julian Lam
26946c7fe6 fix: only send Update(Actor) to followers, #12674 2024-07-05 11:14:41 -04:00
Julian Lam
2cbd63dc31 docs: update comment to reflect new behaviour 2024-07-05 11:09:42 -04:00
Julian Lam
a748068c05 fix: missing sorted set member removal on category unfollow 2024-07-04 17:54:04 -04:00
Julian Lam
eab231ee9f fix: add timestamp to follow activity to make it unique 2024-07-04 17:42:59 -04:00
Julian Lam
cb5e5b4bdb fix: additional logging for activities with no context property 2024-07-04 17:00:30 -04:00
Julian Lam
600b1a8622 fix: update handling of Announce(Create(Note)) to also handle non-note types 2024-07-04 17:00:14 -04:00
Julian Lam
7e23e192d8 chore: restore activity history check in AP inbox middleware now that NodeBB sends unique IDs with applicable activities 2024-07-04 14:59:10 -04:00
Barış Soner Uşaklı
644bcec562 perf: use batch in resolveInboxes 2024-07-03 22:35:46 -04:00
Barış Soner Uşaklı
447b99423f Merge branch 'develop' into activitypub 2024-07-03 13:47:18 -04:00
Julian Lam
20aee8e9ae refactor: context item retrieval logic 2024-06-28 16:28:48 -04:00
Julian Lam
cc0bbcb7bf fix: push instead of unshift, now that order is reversed 2024-06-28 16:28:34 -04:00
Julian Lam
a2fb939b5c fix: wrong property name for OrderedCollection items 2024-06-28 15:14:22 -04:00
Julian Lam
e75ec39b70 feat: topic backfill logic via resolvable context #12647 2024-06-28 12:54:41 -04:00
Julian Lam
615aaa01d6 fix: improper sourceContent in mocks 2024-06-28 12:54:41 -04:00
Barış Soner Uşaklı
19908038c5 Merge branch 'develop' into activitypub 2024-06-27 17:00:28 -04:00
Julian Lam
d6f44d8ec3 fix: #12662, add original activity actor to Announced activity's cc 2024-06-27 14:05:48 -04:00
Barış Soner Uşaklı
6b33faac54 Merge branch 'develop' into activitypub 2024-06-27 10:40:14 -04:00
Julian Lam
4dbb73a433 fix: remove duplicate 1b12 announce in notes.assert (already handled in inbox) 2024-06-26 14:32:48 -04:00
Julian Lam
fb1a9178cc Merge branch 'develop' into activitypub 2024-06-25 13:04:48 -04:00
Julian Lam
fe70a2b8b5 fix: #12652, incorrect user, post, topic counters with AP enabled 2024-06-25 12:36:11 -04:00
Julian Lam
ef97a784cf fix: incorrect activity id sent out on 1b12 announce 2024-06-25 12:19:24 -04:00
Julian Lam
09d8fbf978 feat: send out Update(Actor) when a category is edited
This commit also updates the activity sent out when a user profile is edited. The activity is now sent to all known actors.

closes #12655
2024-06-25 11:38:13 -04:00
Julian Lam
b66d998d37 fix: #12654, incorrect location of sharedInbox property 2024-06-25 11:29:20 -04:00
Julian Lam
a371c9fceb fix: regression that caused new notes to have undefined cid and fail privilege check 2024-06-25 11:04:45 -04:00
Julian Lam
89dd2fb04a fix: move call to retrieve cid followers out of loop 2024-06-25 11:04:45 -04:00
Barış Soner Uşaklı
16fe85e2e4 chore: up harmony 2024-06-24 22:31:11 -04:00
Barış Soner Uşaklı
e84c797c7c Merge branch 'develop' into activitypub 2024-06-24 22:30:03 -04:00
Barış Soner Uşaklı
789520e445 chore: up themes 2024-06-23 11:10:36 -04:00
Barış Soner Uşaklı
7a5588da7b chore: up harmony 2024-06-23 10:45:03 -04:00
Barış Soner Uşaklı
1be3f49e75 Merge branch 'develop' into activitypub 2024-06-22 09:00:04 -04:00
Julian Lam
16f8f536da fix: do not retrieve remote outbox count when asserting actor
This change means that a remote user's post count is only the number
of posts they have stored locally. This is easier to reconcile with
the profile UI since showing the artificial number could cause issues
if the local instance contains fewer or no posts by that user.

fixes #12646
2024-06-21 16:48:17 -04:00
Barış Soner Uşaklı
0573e8f3de Merge branch 'develop' into activitypub 2024-06-21 14:53:48 -04:00
Barış Soner Uşaklı
f35a6c631e Merge branch 'develop' into activitypub 2024-06-21 14:47:06 -04:00
Julian Lam
b691d2a9b2 chore: bump version to 4.0.0-alpha 2024-06-21 12:59:55 -04:00
Barış Soner Uşaklı
c8b39007de Merge branch 'develop' into activitypub 2024-06-21 11:24:23 -04:00
Julian Lam
d9ac7f4995 fix: double sanitization 2024-06-20 22:25:09 -04:00
Julian Lam
8003946feb Revert "fix: change setImmediate to a 5s timeout to give plugins (or anything waiting for the return value of the API call) a chance to finish execution"
This reverts commit 7fada44bf2.
2024-06-20 22:20:41 -04:00
Barış Soner Uşaklı
7d679db28e fix: dont load cid=-1 in tag filter dropdown 2024-06-20 10:40:33 -04:00
Julian Lam
7fada44bf2 fix: change setImmediate to a 5s timeout to give plugins (or anything waiting for the return value of the API call) a chance to finish execution 2024-06-19 18:09:02 -04:00
Julian Lam
4b86b37889 fix: openapi spec for v3 posts get 2024-06-19 18:08:49 -04:00
Julian Lam
a3c6c872b9 fix: wrap ap note creation in setImmediate to let internal hooks have a chance to wrap up first 2024-06-19 13:48:00 -04:00
Julian Lam
daed74134a fix(deps): bump markdown 2024-06-19 12:44:50 -04:00
Julian Lam
59818031bc fix: improper sanitization and parsing in mocks.note
- sanitize-html invocation was stripping out images by default, now added as an exception
- only post content was passsed into filter:parse.post, but hook expects post summary
2024-06-19 11:24:24 -04:00
Barış Soner Uşaklı
5a2fa52ccc fix: remove parseInt on users controller 2024-06-19 11:04:09 -04:00
Barış Soner Uşaklı
b3b34ebad6 Merge branch 'develop' into activitypub 2024-06-19 11:03:01 -04:00
Barış Soner Uşaklı
aae0b5b09f chore: up harmony 2024-06-18 10:06:24 -04:00
Barış Soner Uşaklı
529cd3712b Merge branch 'develop' into activitypub 2024-06-17 18:07:46 -04:00
Julian Lam
4eb998d59d fix: send additional @context entry for publicKey support, fixes #12604 2024-06-17 16:27:59 -04:00
Julian Lam
e6e6c2a28a fix: null case handling 2024-06-17 16:01:36 -04:00
Julian Lam
6e2178b0dc feat: instance-level allow andd deny list for federatioN 2024-06-17 15:50:27 -04:00
Julian Lam
d0a1ebcff7 feat: store encountered instances by last seen date 2024-06-17 15:08:22 -04:00
Julian Lam
4b8a9e58ae fix: category follow schema test 2024-06-17 14:47:00 -04:00
Barış Soner Uşaklı
a458bdd638 serve nodeinfo/2.0.json as well 2024-06-17 11:54:00 -04:00
Barış Soner Uşaklı
b540ecd313 chore: up harmony 2024-06-17 11:24:49 -04:00
Barış Soner Uşaklı
c021e7e80f refactor: announces
store number of announces on post hash, show announces like votes, with tooltip and a way to see all, remove them from topic.events so they dont load all tid:<tid>:posts everytime topic is loaded
2024-06-17 11:18:48 -04:00
Barış Soner Uşaklı
05b7828e33 Merge branch 'develop' into activitypub 2024-06-17 09:18:38 -04:00
Barış Soner Uşaklı
18f9baffbc chore: up harmony 2024-06-16 22:59:58 -04:00
Barış Soner Uşaklı
232fcc005f Merge branch 'develop' into activitypub 2024-06-16 22:58:45 -04:00
Barış Soner Uşaklı
bb6f978508 Merge branch 'develop' into activitypub 2024-06-15 14:18:13 -04:00
Barış Soner Uşaklı
6042f4a7ed test: add attachments 2024-06-15 13:56:24 -04:00
Barış Soner Uşaklı
ad22b54e5e perf: get rid of getPidByIndex, use postAtIndex 2024-06-15 13:54:37 -04:00
Barış Soner Uşaklı
820d576481 Merge branch 'develop' into activitypub 2024-06-15 13:45:43 -04:00
Barış Soner Uşaklı
ddda8feb21 encode both sides 2024-06-14 21:15:45 -04:00
Barış Soner Uşaklı
d550dc2728 Merge branch 'develop' into activitypub 2024-06-14 21:15:19 -04:00
Barış Soner Uşaklı
c231ab78b2 refactor: single db call 2024-06-14 20:49:16 -04:00
Barış Soner Uşaklı
c276aa1a99 Merge branch 'develop' into activitypub 2024-06-14 20:45:39 -04:00
Barış Soner Uşaklı
15797f6aa8 Merge branch 'develop' into activitypub 2024-06-14 20:01:55 -04:00
Julian Lam
4030c09e27 fix: getCidByPid call returning improper values because a remote url to a local post was passed in 2024-06-14 14:09:52 -04:00
Julian Lam
6569066ef8 feat: sending out 1b12-style Announce activities on incoming Create/Update/Like/Delete/Undo(Like) activities, when they pertain to notes
re: #12434
2024-06-14 13:55:41 -04:00
Julian Lam
c8bc6e551b fix: 1b12 conformance for inbox.create as well 2024-06-14 13:37:46 -04:00
Julian Lam
931a0f0a60 chore: info -> verbose logging in ap inbox.js 2024-06-14 13:31:08 -04:00
Julian Lam
6069bee019 chore: make getParentChain logging verbose 2024-06-14 13:26:56 -04:00
Julian Lam
89d2363d5e fix: re-add sending of Announce(Note) for microblog support, #12434 2024-06-14 13:26:04 -04:00
Julian Lam
be95b5b122 fix: FEP 1b12 conformance, federate Announce(Create(Note)) instead of Announce(Note), #12434 2024-06-14 13:17:13 -04:00
Julian Lam
f5b856f4fd feat: assert actors when mocking post 2024-06-14 12:22:11 -04:00
Barış Soner Uşaklı
3f1b04ebe8 Merge branch 'develop' into activitypub 2024-06-14 12:14:45 -04:00
Julian Lam
e9a0d49593 fix: missing openapi schema change 2024-06-14 11:57:38 -04:00
Julian Lam
f6d4d5630e chore: lint 2024-06-14 11:49:25 -04:00
Julian Lam
691f691749 debug: comment out actor assertion call in Users.getUsersFields 2024-06-14 11:45:03 -04:00
Julian Lam
827a91ea6b Revert "fix: eliminate infinite loop in actors.assert via user.getUsersFields"
This reverts commit 18e583184f.
2024-06-14 11:45:03 -04:00
Barış Soner Uşaklı
632ba51c7f Merge branch 'develop' into activitypub 2024-06-14 11:30:00 -04:00
Julian Lam
18e583184f fix: eliminate infinite loop in actors.assert via user.getUsersFields 2024-06-14 10:59:07 -04:00
Julian Lam
6227ab900e refactor: actor pruning logic
Remove re-assertion set as it is expensive to re-assert all old user accounts.

Update actor assertion logic to always re-assert a passed-in id if their account's last crawl date is older than the configurable pruning threshold.

fixes #12636
2024-06-14 10:44:30 -04:00
Barış Soner Uşaklı
937caecdc4 Merge branch 'develop' into activitypub 2024-06-14 10:36:06 -04:00
Julian Lam
1219d7d3b8 fix: minor adjustment in logic to work around relation logic 2024-06-14 10:20:53 -04:00
Barış Soner Uşaklı
839928b390 another one 2024-06-14 07:44:10 -04:00
Barış Soner Uşaklı
c95075e1ca perf: '-inf' faster 2024-06-14 07:29:37 -04:00
Barış Soner Uşaklı
291bf7db41 perf: move attachments to post hash 2024-06-13 19:49:04 -04:00
Barış Soner Uşaklı
f76a586328 refactor: catch errors 2024-06-13 18:36:05 -04:00
Julian Lam
b6ffc47c5d feat: check to/cc/audience for local category and put topic there instead (overrides passed-in cid)
closes #12634
2024-06-13 17:06:58 -04:00
Julian Lam
d9f1fa3ad3 feat: plumb audience into _activitypub when mocking posts 2024-06-13 17:05:37 -04:00
Julian Lam
3567f55a5d feat: send cause to ap.get error handler, delete local account representation if assertion fails with a 410 2024-06-13 14:53:47 -04:00
Julian Lam
101062cb92 fix: introduce artificial 5s delay for 1b12 announces 2024-06-13 14:35:09 -04:00
Julian Lam
acd1630b9f fix: move logic to move topic on note assertion earlier in the flow so it actually runs 2024-06-13 14:02:26 -04:00
Barış Soner Uşaklı
badb7e2c05 fix: post count 2024-06-13 11:08:23 -04:00
Barış Soner Uşaklı
578a53dd8e feat: #12589, add nodeinfo endpoint 2024-06-13 10:57:43 -04:00
Barış Soner Uşaklı
919b037a8f Merge branch 'develop' into activitypub 2024-06-12 22:58:04 -04:00
Barış Soner Uşaklı
68c916503a chore: up mentions 2024-06-12 20:46:16 -04:00
Barış Soner Uşaklı
3eca978509 refactor: add method from mentions into core
turn remote url into local profile urls if they are found in remoteUrl:uid
2024-06-12 20:31:36 -04:00
Barış Soner Uşaklı
a67e1b7caf Merge branch 'activitypub' of https://github.com/NodeBB/NodeBB into activitypub 2024-06-12 16:04:16 -04:00
Barış Soner Uşaklı
b2d03da96d refactor: dont need exist check, if post doesnt exist getPostData returns null 2024-06-12 16:04:14 -04:00
Julian Lam
f29214e007 fix: skip follower count check if cid follower is found 2024-06-12 14:15:43 -04:00
Barış Soner Uşaklı
cdc6f9f6a3 check if ap enabled 2024-06-12 00:18:29 -04:00
Barış Soner Uşaklı
2b6e31a635 chore: up mentions 2024-06-12 00:04:03 -04:00
Barış Soner Uşaklı
f075a8c148 Merge branch 'develop' into activitypub 2024-06-11 18:28:34 -04:00
Barış Soner Uşaklı
194a9fca44 add missing nconf 2024-06-11 18:20:53 -04:00
Barış Soner Uşaklı
04b4c16e27 Merge branch 'develop' into activitypub 2024-06-11 18:18:14 -04:00
Opliko
42f514b793 feat: add timestamp to most activity ids 2024-06-11 22:55:45 +02:00
Julian Lam
bb0360bf01 chore: commenting out logic that drops requests if the id has already been seen, due to a regression in interoperability between NodeBB instances 2024-06-11 14:17:40 -04:00
Barış Soner Uşaklı
34dcb44992 Merge branch 'develop' into activitypub 2024-06-11 11:53:51 -04:00
Julian Lam
7313d5c614 feat: show category followers in category federation ACP page 2024-06-11 11:24:25 -04:00
Barış Soner Uşaklı
2c87c6e82f chore: up mentions 2024-06-11 07:32:02 -04:00
Barış Soner Uşaklı
552b07b258 Merge branch 'develop' into activitypub 2024-06-10 20:43:42 -04:00
Barış Soner Uşaklı
634e9b03f1 fix typo 2024-06-10 19:27:03 -04:00
Barış Soner Uşaklı
89465ec604 fix: don't crash process on error in cronjobs
catch error to continue in actor.prune
2024-06-10 19:24:06 -04:00
Barış Soner Uşaklı
bad094e888 chore: up mentions 2024-06-10 19:01:47 -04:00
Barış Soner Uşaklı
a8d41978e5 parse teasers as plain text 2024-06-10 19:01:02 -04:00
Barış Soner Uşaklı
eb6fe3397e refactor: User.getUidsByUserslugs, so it works with multiple slugs 2024-06-10 17:57:39 -04:00
Barış Soner Uşaklı
00c70ce7b0 Merge branch 'develop' into activitypub 2024-06-10 17:30:10 -04:00
Julian Lam
ae3fa85f40 fix: handle missing fullname in actor removal logic 2024-06-10 15:18:32 -04:00
Julian Lam
6272d059e7 fix: guard against infinite loop during topic purge batch call 2024-06-10 13:50:10 -04:00
Barış Soner Uşaklı
57913be591 Merge branch 'develop' into activitypub 2024-06-10 13:49:37 -04:00
Julian Lam
bcfac00bec fix: unintentional parseInt during post purge 2024-06-10 12:30:48 -04:00
Barış Soner Uşaklı
dac2cd2549 Merge branch 'develop' into activitypub 2024-06-10 12:19:07 -04:00
Barış Soner Uşaklı
4f2ccdaa9f Merge branch 'develop' into activitypub 2024-06-09 23:26:44 -04:00
Barış Soner Uşaklı
3488a4bfe1 refactor: suggest topics, use strings for tids
limit search results
2024-06-09 23:25:56 -04:00
Barış Soner Uşaklı
70625133ae fix: pid in api call 2024-06-09 21:04:55 -04:00
Barış Soner Uşaklı
0d645c994f Merge branch 'develop' into activitypub 2024-06-09 13:55:02 -04:00
Barış Soner Uşaklı
58668b46d3 chore: up markdown 2024-06-09 12:48:30 -04:00
Barış Soner Uşaklı
dbd901d318 chore: up markdown 2024-06-09 12:46:02 -04:00
Barış Soner Uşaklı
ded05b83fc Merge branch 'develop' into activitypub 2024-06-08 20:52:11 -04:00
Barış Soner Uşaklı
1ac793ec2b Merge branch 'develop' into activitypub 2024-06-08 16:50:54 -04:00
Barış Soner Uşaklı
869df3c2bc refactor: unused winston 2024-06-08 12:44:01 -04:00
Barış Soner Uşaklı
6f831d1ce2 Merge branch 'develop' into activitypub 2024-06-08 12:36:51 -04:00
Barış Soner Uşaklı
b1f9ad5534 Squashed commit of the following:
commit 4e0e792232
Merge: 24d0999fb5 70b4a0e2ae
Author: Barış Soner Uşaklı <barisusakli@gmail.com>
Date:   Fri Jun 7 19:26:49 2024 -0400

    Merge branch 'master' into develop

commit 70b4a0e2ae
Author: Barış Soner Uşaklı <barisusakli@gmail.com>
Date:   Fri Jun 7 19:14:13 2024 -0400

    feat: allow passing min,max to sortedSetsCardSum

    to get rid of multiple db calls in profile page

commit 6bbe3d1c4c
Author: Barış Soner Uşaklı <barisusakli@gmail.com>
Date:   Fri Jun 7 14:08:48 2024 -0400

    fix: dont show error alert when user user mouse overs votes

    if they dont have permission to view votes

commit 24d0999fb5
Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Date:   Thu Jun 6 13:49:14 2024 -0400

    fix(deps): update dependency pg-cursor to v2.11.0 (#12617)

    Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

commit bee05fe212
Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Date:   Thu Jun 6 13:28:59 2024 -0400

    fix(deps): update dependency pg to v8.12.0 (#12616)

    Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-06-07 19:27:44 -04:00
Julian Lam
6ecc791db9 fix: missing await on topic purge 2024-06-07 16:48:05 -04:00
Julian Lam
4bb2c1a85e feat: logic for remote user deletion, cronjob, and ACP options for pruning options
re: #12611
2024-06-07 16:28:07 -04:00
Julian Lam
8d790964be fix: don't attempt user reset token cleaning if uid is not a number 2024-06-07 16:28:07 -04:00
Barış Soner Uşaklı
d442251f22 Merge branch 'activitypub' of https://github.com/NodeBB/NodeBB into activitypub 2024-06-07 12:58:10 -04:00
Barış Soner Uşaklı
f83a3672ca fix: set 2024-06-07 12:58:04 -04:00
Julian Lam
539300ffec feat: remote user deletion logic, #12611 2024-06-07 12:55:54 -04:00
Julian Lam
3dca79dd3b feat: allow user.exists to respond to requests for remote uids too 2024-06-07 12:55:54 -04:00
Julian Lam
c6f2155f6a fix: incorrect use of .map on a Set 2024-06-07 12:55:54 -04:00
Barış Soner Uşaklı
d1625d5fd2 refactor: change to info 2024-06-07 12:54:11 -04:00
Barış Soner Uşaklı
29bf99ac85 Squashed commit of the following:
commit 24d0999fb5
Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Date:   Thu Jun 6 13:49:14 2024 -0400

    fix(deps): update dependency pg-cursor to v2.11.0 (#12617)

    Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

commit bee05fe212
Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Date:   Thu Jun 6 13:28:59 2024 -0400

    fix(deps): update dependency pg to v8.12.0 (#12616)

    Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-06-07 12:13:57 -04:00
Barış Soner Uşaklı
2ae5857005 refactor: remove verbose logs, 2024-06-07 12:13:28 -04:00
Barış Soner Uşaklı
ba2d18418a refactor: comment out verbose logs 2024-06-07 11:56:58 -04:00
Barış Soner Uşaklı
f8d9f644e6 fix: another crash 2024-06-07 11:55:21 -04:00
Barış Soner Uşaklı
eaf435413e refactor: get rid of noop 2024-06-06 21:10:24 -04:00
Barış Soner Uşaklı
35eb2d0d46 fix: closes #12618, handle missing selector
catch errors in activitypub api missing await on next()
2024-06-06 20:59:02 -04:00
Barış Soner Uşaklı
119230d7ec Merge branch 'develop' into activitypub 2024-06-06 13:18:12 -04:00
Julian Lam
c680fa67d9 chore: update ap cron job config 2024-06-05 12:31:24 -04:00
Barış Soner Uşaklı
c4c3c20789 chore: up mentions 2024-06-05 10:58:04 -04:00
Barış Soner Uşaklı
9868a728bf refactor: single cache.del call 2024-06-04 21:14:11 -04:00
Julian Lam
709c91329a feat: pruning of stale notes older than 30 days with no engagement 2024-06-04 14:18:22 -04:00
Barış Soner Uşaklı
300cf79c95 add max to caches to limit memory usage 2024-06-04 12:31:13 -04:00
Barış Soner Uşaklı
6f37825b1a fix: crash in mocks.profile 2024-06-04 12:30:47 -04:00
Julian Lam
76290e3789 fix: bug that stopped remote users with dashes in their handle from being properly asserted 2024-06-04 10:55:34 -04:00
Julian Lam
4cec67832c chore: bump harmony 2024-06-03 15:58:25 -04:00
Julian Lam
47406d1309 fix: parse incoming HTML to remove useless classes too 2024-05-30 12:04:37 -04:00
Julian Lam
547fc17dcf fix: update post cache clearing logic to call helper function
regression from 8cf9617630
2024-05-28 12:01:30 -04:00
Julian Lam
f2e43e894b Merge remote-tracking branch 'origin/develop' into activitypub 2024-05-27 12:56:51 -04:00
Julian Lam
5ec24977db chore: bump mentions 2024-05-24 14:20:46 -04:00
Julian Lam
ff53f3c02c feat: save remote user urls backreference 2024-05-24 14:11:06 -04:00
Julian Lam
7c79e2310a feat: add another sanitization round before federating to remove classes from all tags in output html, closes #12573 2024-05-23 15:29:32 -04:00
Julian Lam
09e71e94a2 Merge branch 'develop' into activitypub 2024-05-23 15:18:56 -04:00
Julian Lam
b6653d153a fix: have replies provide name when mocked 2024-05-23 12:36:03 -04:00
Julian Lam
e536223ed0 Merge remote-tracking branch 'origin/develop' into activitypub 2024-05-21 13:49:28 -04:00
Julian Lam
4fd0d27e21 chore: bump markdown 2024-05-21 11:16:35 -04:00
Julian Lam
8cf9617630 feat: passing in types to parsePost for more specific handling by plugins 2024-05-17 15:22:13 -04:00
Barış Soner Uşaklı
78e11d6eba chore: up mentions 2024-05-15 14:11:19 -04:00
Julian Lam
f93b237ef1 fix: lint 2024-05-14 12:14:39 -04:00
Julian Lam
a005440088 fix: remove .only 2024-05-14 12:13:23 -04:00
Julian Lam
228c9173f3 chore: bump harmony 2024-05-14 12:12:56 -04:00
Julian Lam
4e9cd8efc0 feat: track incoming requests by id, analytics increment for some metrics, ignore repeated requests by id
closes #12574
2024-05-14 12:07:18 -04:00
Julian Lam
b106a6a018 feat: added analytics.peek 2024-05-14 12:05:53 -04:00
Barış Soner Uşaklı
70d2a6249d Merge branch 'develop' into activitypub 2024-05-12 21:24:27 -04:00
Julian Lam
129017f735 fix: minor cleanup 2024-05-10 17:00:27 -04:00
Julian Lam
1fa9346563 refactor: #12553, buildRecipients so that it is separate from addressing in mocks.js 2024-05-10 15:30:45 -04:00
Julian Lam
b5b89038ac fix: Notes.delete to delete announcer zsets too 2024-05-10 14:40:04 -04:00
Julian Lam
2d7ca48d42 fix: rename api.activitypub.create.post to .note() 2024-05-10 14:32:00 -04:00
Julian Lam
d40d0b06ac fix: canPurge logic fault 2024-05-10 12:39:40 -04:00
Julian Lam
5bfde4c4a5 fix: allow remote content to skip isReady check on topic creation/reply 2024-05-10 12:37:02 -04:00
Julian Lam
0a1ad87d64 fix: Topic.isOwner check to handle remote users 2024-05-10 12:32:21 -04:00
Julian Lam
b67a7c3c15 fix: special handling for privsTopics.canPurge for remote posts 2024-05-10 12:13:36 -04:00
Julian Lam
9fbd48b152 fix: Posts.isMain cast pid to String instead 2024-05-10 12:07:28 -04:00
Julian Lam
9f44e99fae fix: special logic for purge privilege checks 2024-05-10 11:47:57 -04:00
Julian Lam
eda97da14a fix: unnecessary parseInt in post purge api method 2024-05-10 11:28:12 -04:00
Julian Lam
336ea1eeb6 fix: type error in attachment emptying method 2024-05-10 11:18:33 -04:00
Barış Soner Uşaklı
baada2fca7 fix: tag notification if pid is not int 2024-05-10 10:21:28 -04:00
Julian Lam
ccd187e000 feat: Note deletion logic and refactoring, #12551 2024-05-09 15:49:03 -04:00
Julian Lam
fdba684049 chore: some linting 2024-05-09 15:49:03 -04:00
Opliko
c973a9caba fix: encode most uses of pid to avoid more bugs like #12545 2024-05-09 11:25:05 +02:00
Opliko
43dfb60f91 fix: allow quoting AP posts, fixes #12545 2024-05-09 10:48:42 +02:00
Julian Lam
f404395962 feat: handle receipt of Update(Tombstone)
re: #12551
2024-05-07 14:01:31 -04:00
Julian Lam
6f9fa66006 fix: Update(Note) federation should only apply to local content 2024-05-07 12:43:27 -04:00
Julian Lam
96db519b79 feat: on post delete, federate out an Update(Tombstone) 2024-05-07 12:37:57 -04:00
Julian Lam
51089a347a fix: attach announcers and their followers to cc, not to targets, only attach announcers to targets 2024-05-07 12:34:07 -04:00
Julian Lam
5e9d47a1d8 feat: serve Tombstone objects for soft deleted posts
re: #12551
2024-05-07 12:16:23 -04:00
Julian Lam
0c0f01b560 test: refactor tests to support additional Note tests 2024-05-07 11:57:58 -04:00
Julian Lam
2cd9088c8d fix: bump harmony 2024-05-07 10:58:53 -04:00
Julian Lam
ac74d9c55e fix: inability to query local content via remote url 2024-05-07 10:58:39 -04:00
Julian Lam
9761526710 feat: ability to query remote users by webfinger handle 2024-05-07 10:11:36 -04:00
Opliko
a7aeabc80d fix: ensure consistent return type from notes.assert
For fully cached topics it returned bare tid instead of a { tid, count } object.

Typescript would fix this btw :)
2024-05-07 10:40:47 +02:00
Opliko
af98675808 fix: remove unnecessary check and guard against unsupported URIs before fetching 2024-05-06 23:57:47 +02:00
Opliko
22b42f11dd fix: handle retry queue invalidation across a cluster 2024-05-06 23:16:58 +02:00
Julian Lam
4cbb1f2a42 feat: support the ability to search for posts and users by url 2024-05-06 17:11:38 -04:00
Opliko
729f0fcac1 fix: use batch.processArray properly 2024-05-06 23:06:06 +02:00
Opliko
ea6e15bf08 refactor: use batch.processArray for sending AP messages 2024-05-06 22:52:48 +02:00
Opliko
50bc9a37c5 feat: basic retry queue 2024-05-06 22:49:31 +02:00
Julian Lam
4e7b12b925 fix: if an unknown post is navigated to by a logged-in user, automatically assert the post and add it to their inbox 2024-05-06 15:54:45 -04:00
Julian Lam
807c3eac12 feat: on post edit, also target anyone who announced the post and their followers
re: #12537
2024-05-06 15:14:32 -04:00
Julian Lam
e341a5d868 fix: bump harmony 2024-05-06 15:13:43 -04:00
Julian Lam
4e006adb58 fix: bump composer-default 2024-05-06 15:06:30 -04:00
Julian Lam
738d47c7db fix: save followersUrl to remote user hashes 2024-05-06 14:46:26 -04:00
Julian Lam
8de2352e64 fix: notifications for tag followers not being sent if author is a remote user 2024-05-06 11:13:33 -04:00
Julian Lam
05cdb7d920 fix: send topic title in context object 2024-05-03 14:43:04 -04:00
Julian Lam
109f423211 fix: partOf and type 2024-05-03 14:21:46 -04:00
Julian Lam
1a21c8add4 fix: if post count is less than posts per page, don't paginate in ordered collection 2024-05-03 14:16:32 -04:00
Julian Lam
4b29a1b91d refactor: context actor to serve an as:OrderedCollection instead of as:Page, and added context property (as per FEP-7888) to refer to it, changed audience to point to category 2024-05-03 14:11:25 -04:00
Opliko
32d1adf67d feat: add id to reject activity 2024-05-03 17:48:09 +02:00
Barış Soner Uşaklı
ae589ad71c Merge branch 'activitypub' of https://github.com/NodeBB/NodeBB into activitypub 2024-05-03 11:28:25 -04:00
Barış Soner Uşaklı
f24a1a34ce dont show tags from cid -1 2024-05-03 11:27:52 -04:00
Opliko
0336e8f0ad fix: send id for category announces 2024-05-03 17:15:55 +02:00
Julian Lam
3636dec76b chore: debug log 2024-05-01 14:47:07 -04:00
Julian Lam
119800d936 refactor: post announce logic to save to separate zset instead of to topic events, closes #12536 2024-05-01 14:44:29 -04:00
Julian Lam
5e2031977a fix: post deletion of remote posts 2024-04-30 21:59:27 -04:00
Julian Lam
6ac5a77976 fix: update title generator to include the punctuation, and also take into account question marks and exclamation marks 2024-04-30 11:41:34 -04:00
Julian Lam
fc3f3a7831 feat: openapi spec for acp category federation page 2024-04-30 11:20:59 -04:00
Julian Lam
449ffd66c3 fix: remove unneeded flagged property in favour of flagId, use existing language key for rescind-report
cc @oplik0
2024-04-30 10:57:25 -04:00
Opliko
61a595def2 feat: support rescinding user flags 2024-04-30 01:07:09 +02:00
Opliko
53b352d0d8 refactor: remove unnecessary flag routes added in 9ab1a2d 2024-04-30 00:39:32 +02:00
Julian Lam
b9d6aa4639 feat: slightly better title generation 2024-04-29 16:16:07 -04:00
Julian Lam
cf291a518d feat: help modal for world page 2024-04-29 12:48:06 -04:00
Julian Lam
b7ad4317cc fix: have category sync routes return full api response, openapi schema for that pair of routes 2024-04-29 11:45:40 -04:00
Julian Lam
596a5e4ba2 fix: update signature parsing logic to handle values with equal signs in them, closes #12538 2024-04-28 23:25:46 -04:00
Julian Lam
4d777552d8 Merge branch 'develop' into activitypub 2024-04-26 14:12:48 -04:00
Julian Lam
7645365007 tests: fix some but not all tests 2024-04-26 14:10:42 -04:00
Julian Lam
40b9dabda3 fix: unintentional object pollution by buildForSelectCategories 2024-04-26 13:43:38 -04:00
Barış Soner Uşaklı
1ce986f7b5 chore: up widgets 2024-04-26 12:17:51 -04:00
Barış Soner Uşaklı
c041108b47 dont show tags from cid=-1. clicking it doesnt show topics 2024-04-26 12:15:14 -04:00
Barış Soner Uşaklı
fd35dd17b1 make /top faster too 2024-04-26 12:12:48 -04:00
Julian Lam
94eafe1df3 refactor: split activitypub tests to subfolder files 2024-04-26 11:30:08 -04:00
Julian Lam
5e776088c9 chore: lint 2024-04-26 10:45:53 -04:00
Julian Lam
e9df15bbb8 Merge branch 'develop' into activitypub 2024-04-26 10:41:18 -04:00
Julian Lam
af324dae55 feat: remove /world/all route, add intro header to /world 2024-04-25 15:47:00 -04:00
Julian Lam
7180819c21 Revert "fix: make sort work with world filters"
This reverts commit cd8a91fe89.
2024-04-25 15:47:00 -04:00
Opliko
ed84eed8a9 fix: better logic for choosing webfinger lookups 2024-04-25 20:05:53 +02:00
Opliko
dd71340af9 fix: don't look up webfinger when it's not necessary 2024-04-25 19:58:55 +02:00
Barış Soner Uşaklı
ee6779445b Merge branch 'activitypub' of https://github.com/NodeBB/NodeBB into activitypub 2024-04-25 11:25:54 -04:00
Barış Soner Uşaklı
a853dabdc4 remove cid=-1 2024-04-25 11:25:50 -04:00
Julian Lam
6964b1fd56 fix: update hasRelation condition to be true if cid is passed in in options 2024-04-25 11:20:34 -04:00
Barış Soner Uşaklı
9829a5300e Merge branch 'activitypub' of https://github.com/NodeBB/NodeBB into activitypub 2024-04-25 11:16:48 -04:00
Barış Soner Uşaklı
91c3434fda Merge branch 'develop' into activitypub 2024-04-25 11:16:42 -04:00
Opliko
1ecbf02a58 fix: handle requests to URI IDs correctly 2024-04-25 17:16:30 +02:00
Opliko
844d1402ba refactor: use URLSearchParams instead of multiple encodeURIComponent 2024-04-25 13:16:05 +02:00
Opliko
700016649d fix: handle URI actor IDs 2024-04-25 12:59:05 +02:00
Opliko
8fd4ae2d8f test: use encoded parameters in webfinger tests 2024-04-25 12:00:31 +02:00
Opliko
3d1e2cde6a fix: percent-encode outgoing webfinger requests
resolves #12531 - note that incoming percent-encoded webfinger works just fine :)
2024-04-25 11:59:20 +02:00
Opliko
ab055a77a2 feat: add id to Like
part of #12483
2024-04-22 11:40:31 +02:00
Julian Lam
2ce3a11645 feat: handle 1b14 announce for remote content too 2024-04-19 23:36:43 -04:00
Julian Lam
59021d8a9f fix: bump harmony 2024-04-17 13:39:45 -04:00
Julian Lam
3721f8196d fix: icon:text and icon:bgColor for remote users 2024-04-17 13:29:26 -04:00
Opliko
f802564e64 feat: resolve Crate and Announce activities 2024-04-17 19:19:09 +02:00
Julian Lam
a9a7fd1ecd fix: handle Announce(Create(Note)) 2024-04-17 12:55:15 -04:00
Julian Lam
dce334461a fix: use system pseudo-user for topic move call 2024-04-16 14:27:21 -04:00
Julian Lam
49a64dc7a7 fix: if the topic already existed in -1, move it to another category if one was passed in options 2024-04-16 14:17:47 -04:00
Julian Lam
452cb0a9aa feat: utility functions to get local followers/counts given a remote actor uri, plumb cid into notes.assert via inbox.announce if a local cid is following the sending actor 2024-04-16 14:00:01 -04:00
Julian Lam
1253ded7d5 chore: minor cleanup 2024-04-16 13:38:05 -04:00
Julian Lam
407dda78e1 fix: add back reverse lookup for notes assertion checking via cids 2024-04-15 16:28:57 -04:00
Julian Lam
59a9dd8436 refactor: stub routes for category synchronization, refactor remote follow logic to allow categories to conduct follows as well 2024-04-15 16:18:12 -04:00
Julian Lam
b7ff7be28f fix: actors.assert should return false if webfinger cannot resolve to an id 2024-04-15 13:37:00 -04:00
Julian Lam
b7629c6ef7 feat: send id back during accepts 2024-04-15 09:48:58 -04:00
Opliko
026449dc4a fix: support reporting remote content in Flag 2024-04-14 02:42:30 +02:00
Opliko
a1a7fb77da feat: handle Undo(Flag) 2024-04-14 02:02:17 +02:00
Opliko
c1dfa8d2fe feat: send undo when rescinding/deleting a report 2024-04-14 01:52:07 +02:00
Opliko
2a2b855fe2 feat: federate flag creation 2024-04-14 00:51:53 +02:00
Opliko
7bacbf76f0 feat: bulk purge flags 2024-04-14 00:20:46 +02:00
Opliko
9ab1a2d129 feat: rescind post flags 2024-04-14 00:07:55 +02:00
Julian Lam
7c4ae8eb82 fix: #12505, convert relative urls in links and images into absolute urls, uses same logic as for emails 2024-04-12 15:30:59 -04:00
Julian Lam
6befff5e11 fix: #12502, improper decrement 2024-04-12 14:46:15 -04:00
Julian Lam
64258dac19 fix: send userslug in preferredUsername 2024-04-12 14:26:37 -04:00
Julian Lam
50f31b236a fix: wrong variable name 2024-04-12 14:13:46 -04:00
Julian Lam
fdbd30dc23 feat: integrate post uploads into attachments for outgoing note mocks 2024-04-12 14:08:28 -04:00
Julian Lam
bb4e418dde fix: change default attachment type to Link, or Image if mediaType matches 2024-04-12 12:56:18 -04:00
Julian Lam
c3365908b8 fix: save modified actor back to req.body 2024-04-12 11:08:31 -04:00
Opliko
4d07ca4c6a feat: add id to profile updates 2024-04-12 16:43:33 +02:00
Opliko
18ba2e38ec feat: noremalize actor property in middleware 2024-04-12 16:42:54 +02:00
Julian Lam
52271caec2 fix: you scurvy cur! 2024-04-11 15:23:19 -04:00
Julian Lam
4d23a837fa fix: handle (created) and (expires) in http signatures 2024-04-11 15:20:59 -04:00
Julian Lam
f783338621 fix: #12496, url in schema 2024-04-11 13:43:29 -04:00
Julian Lam
525b7c2cee feat: save remote post url into hash for use 2024-04-11 13:25:37 -04:00
Julian Lam
f5ea470df4 fix: handle remote userslugs with non-lowercase letters 2024-04-11 12:52:21 -04:00
Julian Lam
8d0876b8d7 fix: send hs2019 as algo (to match cavage-12, handle incoming algorithm value 2024-04-11 10:39:51 -04:00
Julian Lam
6fc6cc33cd feat: note attachments via link preview plugin 2024-04-10 22:01:50 -04:00
Julian Lam
b8daa02fa8 fix: bump harmony 2024-04-10 22:01:50 -04:00
Opliko
959e1820cd fix: throw errors when local objects don't exist 2024-04-10 18:50:41 +02:00
Julian Lam
e0f542c912 fix: #12493, send unescaped fullname through via AP if displayname is set/used 2024-04-10 12:17:07 -04:00
Julian Lam
465a46f2e6 fix: send only the id into actors.assert 2024-04-10 12:17:07 -04:00
Opliko
c92b5e07a7 fix: use a slightly better error code to indicate object resolution failure 2024-04-10 18:15:50 +02:00
Opliko
3cc09d204f feat: add ids for undoing follows 2024-04-10 01:11:49 +02:00
Opliko
66b0d81caf feat: send back follow id in Accept 2024-04-10 00:30:46 +02:00
Opliko
e0166cccb2 fix: assume object is full object and not just id everywhere 2024-04-10 00:26:01 +02:00
Opliko
962c63511c fix: use resolved object in likes 2024-04-10 00:20:16 +02:00
Opliko
b6168202ae fix: reject accepts of invalid follows and return when already following 2024-04-10 00:17:14 +02:00
Opliko
41f77f8f1b fix: handle resolved objects in flag 2024-04-10 00:06:24 +02:00
Opliko
72dbcfe3d9 feat: add id to follows and verify accepts 2024-04-09 23:58:52 +02:00
Opliko
b921f8d167 refactor: use a helper to resolve actor URI 2024-04-09 23:58:25 +02:00
Opliko
102c174e03 feat: resolve objects from ids in middleware 2024-04-09 23:58:00 +02:00
Julian Lam
d437d969cc feat: filter out topics in cid -1 from tagged topics page, closes #12489 2024-04-09 15:13:31 -04:00
Opliko
fcd5447cd4 feat: sign public key requests
Lack of this signature resulted in ironically failing the verification of signed requests from Mastodon instanced configured to require signed get...
2024-04-09 19:27:35 +02:00
Julian Lam
464dd8067d fix: additional verbose logging for signature verification 2024-04-09 11:29:57 -04:00
Julian Lam
6b169e048e fix: bump mentions 2024-04-09 10:48:23 -04:00
Julian Lam
86107535db fix: default to showing alternate as link to the post object 2024-04-08 16:08:41 -04:00
Julian Lam
de83d6b01c fix: pid via index calculation 2024-04-08 15:41:33 -04:00
Julian Lam
4e0d7dd364 feat: populate and send link tag/header respectively for activitypub-enabled content 2024-04-08 14:46:07 -04:00
Opliko
c30c12881c fix: support ldjson with ActivityStreams profile in actor queries 2024-04-08 20:06:26 +02:00
Opliko
2d1524eeff fix: early return on invalid actor 2024-04-06 19:10:49 +02:00
Opliko
e25f06212f fix: use actor id in flags 2024-04-06 19:10:13 +02:00
Opliko
d9eba6d297 feat: basic AP Flag support 2024-04-06 19:00:52 +02:00
Opliko
cd8a91fe89 fix: make sort work with world filters 2024-04-06 02:26:54 +02:00
Opliko
5c1c1d2182 fix: uppercase digest algorithm name for compatibility reasons
Mastodon works with either, but Mbin actually requires uppercased name
2024-04-06 02:06:41 +02:00
Opliko
9fc194e3a8 feat: send unique identifiers for note activities 2024-04-06 01:50:39 +02:00
Opliko
8003b00acd Revert "feat: log all post edits to the event log, return eid when logging events, plumb eid into Update(Note) to federate out as a unique id"
This reverts commit 83392f3ca2.
2024-04-06 01:18:46 +02:00
Julian Lam
cbfc8d252d fix: better handle null return from notes.assert 2024-04-05 16:09:19 -04:00
Julian Lam
86caf237bf fix: bump mentions 2024-04-05 16:04:31 -04:00
Julian Lam
719bb0a0ec chore: add verbose logging to middleware.validate 2024-04-05 16:03:41 -04:00
Julian Lam
f40df38786 fix: actor assertion logic to ignore loopback urls 2024-04-05 11:37:23 -04:00
Julian Lam
983153fbba fix: lol 2024-04-04 13:14:40 -04:00
Julian Lam
42bdffdc94 fix: maybe last tweak 2024-04-04 13:02:16 -04:00
Julian Lam
ae146024af fix: tweak upgrade script again 2024-04-04 12:33:07 -04:00
Julian Lam
166e3e2f95 fix: tweak upgrade script again 2024-04-04 12:26:24 -04:00
Julian Lam
442e98e4da fix: add timeout on activitypub.get, tweak upgrade script 2024-04-04 12:22:13 -04:00
Julian Lam
943cef6da2 feat: upgrade script to re-assert all known actors to save URL into hash, and bump mentions 2024-04-04 12:12:15 -04:00
Julian Lam
0b2faf21f9 Merge remote-tracking branch 'origin/develop' into activitypub 2024-04-03 13:50:10 -04:00
Julian Lam
59709a3cb2 fix: tests, save actor URL into userRemote hash 2024-04-03 13:49:27 -04:00
Julian Lam
1084f21d47 fix: show locally captured remote posts when browsing remote profiles 2024-04-03 11:21:19 -04:00
Julian Lam
e828e87ec7 fix: have getCidsByPrivilege include cid -1 when set is categories:cid 2024-04-02 16:47:44 -04:00
Julian Lam
893788a2fc fix: updateLastOnlineTime for remote uids 2024-04-02 16:43:41 -04:00
Julian Lam
2cbc391382 fix: bump harmony 2024-03-29 23:34:25 -04:00
Opliko
d1fa6a596b feat: sorting an filtering in /world 2024-03-28 17:07:34 +01:00
Opliko
a3dce46371 fix: adjust world schema 2024-03-28 13:20:30 +01:00
Opliko
e266b44745 feat: make the /api/world data more category-like 2024-03-28 13:14:02 +01:00
Opliko
3c6966fb0f feat: add more properties from /category to /world 2024-03-28 11:57:43 +01:00
Opliko
495d5435f5 fix: redirect to /api/world for API requests 2024-03-28 11:54:24 +01:00
Opliko
e0138cbede feat: follow redirects in API 2024-03-28 11:53:49 +01:00
Julian Lam
a38d0c14ab Revert "refactor: use getSortedSetUnion in syncUserInboxes instead"
This reverts commit 6c1a0fb1dc.
2024-03-26 16:41:51 -04:00
Julian Lam
f078e1e267 fix: lint 2024-03-26 13:44:23 -04:00
Julian Lam
4fcd2bb2d9 fix: skip notes.assert checks for Announce(Note) if it's a remote object, do our own checks; #12442 2024-03-26 13:40:18 -04:00
Julian Lam
6c1a0fb1dc refactor: use getSortedSetUnion in syncUserInboxes instead 2024-03-26 13:39:34 -04:00
Julian Lam
5efe8abd15 Revert "add 1 ||"
This reverts commit c2890a3e74.
2024-03-26 13:20:12 -04:00
Julian Lam
dff5d1c6cd fix: getSortedTopics; filter out topics in cid -1 unless explicitly asked for 2024-03-26 13:09:53 -04:00
Barış Soner Uşaklı
0e2daa58aa Merge branch 'develop' into activitypub 2024-03-26 12:59:02 -04:00
Julian Lam
8dcdf8ef3b fix: #12444 add uncategorized topics counted stats to separate sorted set 2024-03-26 11:34:14 -04:00
Barış Soner Uşaklı
c2890a3e74 add 1 || 2024-03-26 10:41:29 -04:00
Barış Soner Uşaklı
550e522fe9 chore: up harmony 2024-03-26 10:37:17 -04:00
Barış Soner Uşaklı
017a57e645 Merge branch 'develop' into activitypub 2024-03-26 10:35:15 -04:00
Julian Lam
55e947a01d feat: add checks to only continue with topic assertion if there is a relation to existing content, #12442 2024-03-26 10:22:17 -04:00
Julian Lam
2688b6bbdc feat: add assertion lock on activitypub.notes.assert 2024-03-25 14:55:25 -04:00
Julian Lam
ac765f1e01 refactor: use one internal rejection method 2024-03-25 13:25:17 -04:00
Barış Soner Uşaklı
2ffdbebd21 Merge branch 'develop' into activitypub 2024-03-22 19:45:59 -04:00
Julian Lam
1fb3c4fda6 fix: undefined object in notes.assert 2024-03-22 16:21:32 -04:00
Julian Lam
bab41d129a fix: typo 2024-03-22 16:01:05 -04:00
Julian Lam
04c743eb4a feat: have category actor send Announce(Note) on remote replies to topics in a cid
#12434
2024-03-22 15:28:01 -04:00
Julian Lam
4ee8519d0c chore: move assertTopic to top of file, rename to assert
... despite the diff, that's all this commit did
2024-03-22 14:54:53 -04:00
Julian Lam
e0f6b7074b fix: restore userOrGroupExists, as alias to slugTaken 2024-03-22 14:42:29 -04:00
Julian Lam
7df5cabb76 feat: have category actor send Announce(Note) activity on posts from that cid
re: #12434
2024-03-22 14:39:20 -04:00
Julian Lam
803975fd97 fix: send handle as category actor's preferredUsername, #12434 2024-03-22 13:06:09 -04:00
Julian Lam
9dc20d0c54 feat: ability to update category handles
#12434
2024-03-22 12:59:24 -04:00
Julian Lam
3cc99a178e feat: category handles, #12434 2024-03-22 12:39:48 -04:00
Julian Lam
aafdefa7d6 fix: use CategoryObject in admins-mods schema def 2024-03-22 12:37:52 -04:00
Julian Lam
65bb866654 fix: only send activitypub+json links via webfinger if activitypub is global enabled 2024-03-22 12:29:41 -04:00
Julian Lam
9c03e6e93c fix: bump harmony 2024-03-22 11:08:40 -04:00
Julian Lam
003c64690f fix: resolve interoperability issues with Misskey, et al.
Full credit to @jfietkau for the hint that led to this fix.
2024-03-21 16:23:43 -04:00
Julian Lam
da2a1e207a fix: bump harmony 2024-03-21 14:55:25 -04:00
Julian Lam
9a5d4ffb2d feat: rename 'world' category to 'uncategorized' 2024-03-21 14:12:31 -04:00
Julian Lam
8f131b71ee fix: issue where cid -1 appeared in all category searches 2024-03-21 14:01:51 -04:00
Julian Lam
17f62f249d feat: plumb pid into return data from posts.loadPostTools, add link to view original post, for remote content
closes #12433
2024-03-21 13:48:07 -04:00
Julian Lam
2756aae7be fix: bump harmony 2024-03-21 00:43:22 -04:00
Julian Lam
2408ecd8dc fix: post urls to use prefix instead of linking out directly to origin server, exposed encodeURIComponent as a template helper for themes 2024-03-21 00:41:53 -04:00
Julian Lam
6fa43b9ef0 Revert "fix: urls in posts and announces"
This reverts commit 4fb6574cf7.
2024-03-20 23:18:12 -04:00
Julian Lam
c9985c5cd6 Revert "fix: missing invocation of generatePostUrl in generateRepliedTo helper"
This reverts commit a08b2efb5e.
2024-03-20 23:11:32 -04:00
Julian Lam
5a2d17dfd1 fix: regression that caused S2S calls to topics to return with a 404 2024-03-20 23:06:45 -04:00
Opliko
d75894eb9b feat: add TTL cache for Actors.assert 2024-03-21 00:25:27 +01:00
Barış Soner Uşaklı
1027c6e6dd Merge branch 'develop' into activitypub 2024-03-20 12:20:57 -04:00
Opliko
953231fc90 feat(activitypub): show world category in selectors
Allows for posting to World, showing World in unread/recent/etc

also removes now-unnecessary special case for privileges
2024-03-18 01:50:11 +01:00
Julian Lam
5f60f75f7d fix: restore accidentally removed calls to updateLocalRecipients and saveAttachments 2024-03-15 16:38:00 -04:00
Julian Lam
c76d4018f2 fix: properly handle empty fields param 2024-03-15 12:07:33 -04:00
Julian Lam
7af0715146 fix: circular object ref.
getCategoriesFields was returning full pseudo-category object even if fields were specific, now it only returns fields requested.
navigating to /category/-1 now redirects to /world
2024-03-15 12:00:23 -04:00
Barış Soner Uşaklı
a84dba271c chore: up harmony 2024-03-14 19:35:29 -04:00
Julian Lam
ccd9c7ed85 fix: tweak maxTags handling 2024-03-14 14:48:35 -04:00
Julian Lam
ff6cc90ca5 fix: bump mentions 2024-03-14 14:43:17 -04:00
Julian Lam
1a92239c15 fix: send topic notif on reply 2024-03-14 13:50:43 -04:00
Julian Lam
44a183dd84 fix: typo 2024-03-14 13:41:04 -04:00
Julian Lam
0a2482cf51 fix: double-OP issue when remote reply comes in to local mainPid 2024-03-14 13:28:57 -04:00
Julian Lam
7aa56c727b fix: filter out system tags and prune extra tags beyond number allowed, when creating topic from remote data 2024-03-14 13:28:57 -04:00
Barış Soner Uşaklı
e389aec288 Merge branch 'develop' into activitypub 2024-03-14 12:12:31 -04:00
Julian Lam
ebd750b4c7 fix: resolve toPid to local pid if able 2024-03-14 00:17:45 -04:00
Julian Lam
c346177bb9 fix: tweak user.search to better handle local usernames with colons in them 2024-03-13 15:41:41 -04:00
Julian Lam
83392f3ca2 feat: log all post edits to the event log, return eid when logging events, plumb eid into Update(Note) to federate out as a unique id 2024-03-13 15:27:59 -04:00
Julian Lam
0bfdbb6a5c feat: if Update(Note) is received for a post that does not already exist, assert it 2024-03-13 15:05:42 -04:00
Julian Lam
c5c0c47393 chore: lint 2024-03-13 11:45:28 -04:00
Julian Lam
81b319f494 fix: accidental saving of ephemeral _activitypub object into db, should plumb into hooks only 2024-03-13 11:19:50 -04:00
Julian Lam
c6624b6341 chore: remove now-unused notes.assert 2024-03-13 11:07:38 -04:00
Julian Lam
519e025e27 feat: return count of new notes in assertTopic 2024-03-13 11:03:08 -04:00
Julian Lam
338c568c99 fix: handle actor assertion failure if mocked profile does not have a fullname 2024-03-13 10:56:00 -04:00
Julian Lam
f7c8967ee2 refactor: use topics.post, topics.reply, and posts.edit in Create(Note), Create(Note), and Update(Note), respectively 2024-03-12 14:24:38 -04:00
Julian Lam
52c143aa03 fix: template helper 2024-03-12 13:27:29 -04:00
Julian Lam
4b2495b5aa fix: tests 2024-03-12 12:03:16 -04:00
Julian Lam
01f7af9291 fix: remove unused param in addParentPosts 2024-03-12 11:39:26 -04:00
Julian Lam
eb8ff75b63 fix: only dereference id if it doesn't exist locally — fewer calls 2024-03-12 11:33:40 -04:00
Julian Lam
b1491ca3cd fix: specify param for api test for /world 2024-03-12 11:08:57 -04:00
Julian Lam
9cd6b496ba fix: AP S2S handling for /user/:userslug route 2024-03-12 10:41:47 -04:00
Barış Soner Uşaklı
ba3eae7a64 Merge branch 'develop' into activitypub 2024-03-11 20:40:08 -04:00
Julian Lam
5768a4377b fix: handle fetch failures on helpers.query 2024-03-11 14:41:05 -04:00
Julian Lam
8bf6130d17 Merge branch 'develop' into activitypub 2024-03-11 11:42:21 -04:00
Julian Lam
ceab77e43b fix: more duplicate detection in to/cc 2024-03-11 11:40:24 -04:00
Julian Lam
17c55922d4 fix: markAsRead to handle remote topics 2024-03-11 00:06:12 -04:00
Julian Lam
90e7d3a1c6 fix: parse questions like a post 2024-03-10 22:24:25 -04:00
Julian Lam
1e6632392b fix: properly build recipients... old logic was just plain wrong :shipit: 2024-03-09 21:09:59 -05:00
Barış Soner Uşaklı
66b4dc2c96 chore: up harmony 2024-03-09 08:55:06 -05:00
Julian Lam
97e5b0fdad fix: reallow topic:read privilege for fediverse group 2024-03-08 20:53:13 -05:00
Julian Lam
08184f29c1 Revert "fix: pass proper uid to privilege check in AP note federation"
This reverts commit 95427c4af7.
2024-03-08 20:45:54 -05:00
Julian Lam
00101d9e29 fix: only serve local posts via S2S when queried 2024-03-08 20:45:51 -05:00
Julian Lam
6423d514ff fix: note not federated out to mentioned users' inboxes 2024-03-08 14:06:23 -05:00
Julian Lam
f31d8c2e9b feat: add /world and /world/all to available nav items 2024-03-08 12:33:21 -05:00
Julian Lam
6aece6893b fix: update styling of available nav items in ACP 2024-03-08 12:33:21 -05:00
Barış Soner Uşaklı
d31978b3a8 chore: up harmony 2024-03-07 18:52:17 -05:00
Barış Soner Uşaklı
c7e2b0a2b0 Merge branch 'develop' into activitypub 2024-03-07 18:50:37 -05:00
Julian Lam
39da3ab668 fix: remove unresolvable actors from parsing prior to mocking profile 2024-03-07 16:59:40 -05:00
Julian Lam
4a8b06e815 fix: send explicitly empty array for attachment prop in mocks.note 2024-03-07 16:47:09 -05:00
Julian Lam
6999bd68e2 fix: remove some privs from fediverse in world category 2024-03-07 15:51:30 -05:00
Julian Lam
28f38b2687 fix: inability to retrieve previously-unknown remote users via url manipulation 2024-03-07 15:39:42 -05:00
Julian Lam
81b81e540a fix: tags received via AP should only be of type Hashtag (as mentions are also in tag prop 2024-03-07 15:11:43 -05:00
Julian Lam
beaedc86aa fix: data schema of reject activities 2024-03-07 13:46:20 -05:00
Julian Lam
9f8c706f23 fix: fallbacks for if preferredUsername is not set 2024-03-07 13:16:13 -05:00
Julian Lam
95427c4af7 fix: pass proper uid to privilege check in AP note federation 2024-03-06 15:07:56 -05:00
Julian Lam
0c2cfbe7a0 chore: lint 2024-03-06 14:59:49 -05:00
Julian Lam
4c2cbb5b12 fix: remove console logging 2024-03-06 14:58:15 -05:00
Julian Lam
5b74f6dac5 fix: move fallback 0 return in parseIntFields to other side of ternary conditional 2024-03-06 11:47:46 -05:00
Julian Lam
f368a1a87f fix: remove intFields hack and update db.parseIntFields to only parseInt if field value is a number 2024-03-06 11:45:34 -05:00
Barış Soner Uşaklı
cda25bb646 Merge branch 'develop' into activitypub 2024-03-06 11:40:41 -05:00
Barış Soner Uşaklı
385b8c8a52 Merge branch 'develop' into activitypub 2024-03-06 11:40:12 -05:00
Barış Soner Uşaklı
706fa17429 Merge branch 'develop' into activitypub 2024-03-06 10:34:36 -05:00
Julian Lam
6671b51169 fix: truthy check in notes.assert 2024-03-05 14:52:52 -05:00
Julian Lam
aef1215fd6 fix: remote user avatars in notifications 2024-03-05 14:42:25 -05:00
Julian Lam
ae09aa2086 fix: bump mentions 2024-03-05 14:26:33 -05:00
Julian Lam
e4c1ca1ede feat: mentions support 2024-03-05 14:26:33 -05:00
Julian Lam
1b64fdb5b3 feat: allow user.search to handle remote handles, beginning of mentions support 2024-03-05 14:26:33 -05:00
Barış Soner Uşaklı
07c1ea2876 Merge branch 'develop' into activitypub 2024-03-04 16:09:11 -05:00
Barış Soner Uşaklı
63e2dc9ae4 string 2024-02-29 16:48:43 -05:00
Barış Soner Uşaklı
038f726b23 testing popular sorting 2024-02-29 16:47:09 -05:00
Julian Lam
c707a32aa9 feat: integrate notifications for remote likes, follows, and announces.
Rescind notification as necessary.
2024-02-29 16:10:01 -05:00
Barış Soner Uşaklı
0f99ae1f45 pre.3 2024-02-29 15:14:55 -05:00
Barış Soner Uşaklı
cb8d38ee2f Squashed commit of the following:
commit 072a11b89f
Merge: 25ef62dc6b 8fb0ed82b8
Author: Barış Soner Uşaklı <barisusakli@gmail.com>
Date:   Thu Feb 29 13:10:01 2024 -0500

    Merge branch 'develop' of https://github.com/NodeBB/NodeBB into develop

commit 25ef62dc6b
Author: Barış Soner Uşaklı <barisusakli@gmail.com>
Date:   Thu Feb 29 13:09:56 2024 -0500

    chore: up harmony

commit 8fb0ed82b8
Author: Misty Release Bot <deploy@nodebb.org>
Date:   Thu Feb 29 18:08:50 2024 +0000

    chore(i18n): fallback strings for new resources: nodebb.themes-harmony

commit 96bc5d6f84
Author: Barış Soner Uşaklı <barisusakli@gmail.com>
Date:   Thu Feb 29 13:08:25 2024 -0500

    add harmony setting label

commit 516c8d0424
Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Date:   Thu Feb 29 12:53:39 2024 -0500

    chore(deps): update commitlint monorepo to v19 (#12369)

    Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

commit a8abc7aea4
Merge: 3aaa072756 aa6859dfae
Author: Barış Soner Uşaklı <barisusakli@gmail.com>
Date:   Thu Feb 29 10:42:42 2024 -0500

    Merge branch 'develop' of https://github.com/NodeBB/NodeBB into develop

commit 3aaa072756
Author: Barış Soner Uşaklı <barisusakli@gmail.com>
Date:   Thu Feb 29 10:42:37 2024 -0500

    chore: up peace

commit aa6859dfae
Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Date:   Thu Feb 29 10:42:16 2024 -0500

    fix(deps): update dependency nodemailer to v6.9.11 (#12380)

    Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

commit 2c3540b25c
Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Date:   Thu Feb 29 10:42:07 2024 -0500

    fix(deps): update dependency express to v4.18.3 (#12379)

    Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

commit c8a5e24aee
Author: Barış Soner Uşaklı <barisusakli@gmail.com>
Date:   Thu Feb 29 10:39:02 2024 -0500

    test: fix test

commit 5556c1d549
Author: Barış Soner Uşaklı <barisusakli@gmail.com>
Date:   Thu Feb 29 10:29:37 2024 -0500

    add missing btn class

commit fb98dc054d
Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Date:   Thu Feb 29 09:43:17 2024 -0500

    fix(deps): update dependency nodebb-plugin-markdown to v12.2.6 (#12371)

    Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

commit 9de0ab3165
Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Date:   Thu Feb 29 09:42:13 2024 -0500

    fix(deps): update dependency archiver to v7 (#12374)

    Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

commit 131d2fe78b
Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Date:   Thu Feb 29 09:41:56 2024 -0500

    fix(deps): update dependency chart.js to v4.4.2 (#12376)

    Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

commit 0ab1982cb8
Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Date:   Thu Feb 29 09:41:37 2024 -0500

    chore(deps): update dependency smtp-server to v3.13.3 (#12378)

    Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

commit 65f3c73493
Author: Barış Soner Uşaklı <barisusakli@gmail.com>
Date:   Thu Feb 29 09:31:17 2024 -0500

    chore: up peace

commit a5101dcbe1
Author: Misty Release Bot <deploy@nodebb.org>
Date:   Thu Feb 29 09:19:05 2024 +0000

    Latest translations and fallbacks

commit 0f11533c23
Author: Barış Soner Uşaklı <barisusakli@gmail.com>
Date:   Wed Feb 28 16:04:25 2024 -0500

    cache page changes

commit 53b3c5d669
Author: Barış Soner Uşaklı <barisusakli@gmail.com>
Date:   Wed Feb 28 11:41:18 2024 -0500

    feat: add chat widget areas, closes #12375

commit 1ed4d76363
Author: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Date:   Wed Feb 28 11:14:20 2024 -0500

    fix(deps): update dependency bootswatch to v5.3.3 (#12373)

    Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

commit ea99401a0d
Merge: f4c651fe25 caf8722fd5
Author: Barış Soner Uşaklı <barisusakli@gmail.com>
Date:   Wed Feb 28 09:19:09 2024 -0500

    Merge branch 'master' into develop

commit caf8722fd5
Author: Misty Release Bot <deploy@nodebb.org>
Date:   Wed Feb 28 14:17:10 2024 +0000

    chore: update changelog for v3.6.7

commit 78b8fab3e7
Author: Misty Release Bot <deploy@nodebb.org>
Date:   Wed Feb 28 14:17:09 2024 +0000

    chore: incrementing version number - v3.6.7

commit f4c651fe25
Author: Misty Release Bot <deploy@nodebb.org>
Date:   Wed Feb 28 09:18:50 2024 +0000

    Latest translations and fallbacks

commit 5eb503440d
Merge: 7207814b90 5f597dc97f
Author: Barış Soner Uşaklı <barisusakli@gmail.com>
Date:   Tue Feb 27 15:22:23 2024 -0500

    Merge branch 'master' into develop

commit 5f597dc97f
Author: Barış Soner Uşaklı <barisusakli@gmail.com>
Date:   Tue Feb 27 15:13:06 2024 -0500

    align center

commit 107f5613bf
Author: Barış Soner Uşaklı <barisusakli@gmail.com>
Date:   Tue Feb 27 15:07:23 2024 -0500

    fix: #12372, fix manual digest buttons

commit 7207814b90
Author: Barış Soner Uşaklı <barisusakli@gmail.com>
Date:   Tue Feb 27 09:20:44 2024 -0500

    feat: add hook into user.posts.isReady

    can be used to disable post delays or apply custom logic
    closes https://github.com/NodeBB/NodeBB/pull/12367

commit 1df37c0153
Author: Misty Release Bot <deploy@nodebb.org>
Date:   Tue Feb 27 09:19:09 2024 +0000

    Latest translations and fallbacks
2024-02-29 15:04:29 -05:00
Julian Lam
e1c4311299 feat: federate tags out on new post (if new topic) 2024-02-29 11:35:07 -05:00
Julian Lam
f5a610797c refactor: use topics.create internal method instead of directly setting topic hash, handle incoming tags for OP 2024-02-29 11:19:56 -05:00
Julian Lam
1ca9994f43 fix: properly handle if mainPid is not a number 2024-02-29 00:06:59 -05:00
Julian Lam
b529610bba fix: mainPid detection in unprocessed ids in assertTopic 2024-02-28 21:50:43 -05:00
Julian Lam
0cf6af9b6c fix: redir on bad world filter 2024-02-28 14:10:21 -05:00
Julian Lam
8cb984d44c fix: serve fallback images on category actor 2024-02-28 14:07:27 -05:00
Julian Lam
44452a3b6e fix: bump markdown 2024-02-28 13:48:49 -05:00
Julian Lam
a08b2efb5e fix: missing invocation of generatePostUrl in generateRepliedTo helper 2024-02-28 13:33:41 -05:00
Julian Lam
bdcd862c1b refactor: assertTopic to only call setObject when it is a new topic 2024-02-28 13:29:21 -05:00
Julian Lam
96a3a7465f fix: unable to process Update(Person) 2024-02-28 13:14:15 -05:00
Julian Lam
d51f5e5922 fix: crash in resolveLocalId if Number is passed in 2024-02-28 12:54:54 -05:00
Julian Lam
0b6b86d132 fix: missing await, and handle local posts with no toPid (i.e. reply to OP) 2024-02-28 12:45:11 -05:00
Julian Lam
a3368a6904 fix: don't overwrite title if present, on assertTopic 2024-02-27 15:25:13 -05:00
Julian Lam
b189427621 fix: bump markdown 2024-02-27 11:48:14 -05:00
Julian Lam
281e9d6e8c fix: copy fixes, rename upgrade script, disable federation on upgrades 2024-02-27 11:35:00 -05:00
Julian Lam
10abb82e8a fix: update ap-related deps to prerelease versions 2024-02-27 11:25:55 -05:00
Julian Lam
3d83a1752e Merge remote-tracking branch 'origin/develop' into activitypub 2024-02-26 16:32:45 -05:00
Julian Lam
77a3efb43c feat: restrict loopback calls 2024-02-26 16:12:40 -05:00
Julian Lam
5f85e70006 fix: defer federation of new topics when topic is scheduled, tie activitypub api module to global enable toggle 2024-02-26 15:39:09 -05:00
Julian Lam
aadac7053a tests: fix privileges in test runner 2024-02-26 14:22:35 -05:00
Julian Lam
563db78f87 fix: lint 2024-02-26 13:47:55 -05:00
Julian Lam
1bd8f9a1fa feat: fine-grained privileges integration for fediverse users and world pseudo-category 2024-02-26 11:39:32 -05:00
Julian Lam
28370b1043 fix: put postcount retrieval behind try..catch so errors are handled appropriately 2024-02-26 11:34:03 -05:00
Julian Lam
b011595198 fix: side-effects from adding toPid to post intFields (now removed) 2024-02-22 10:08:44 -05:00
Julian Lam
3a870360c4 fix: revert introduction of toPid into post intFields, manually cast toPid as int in getPostSummaryByPids if present and numeric 2024-02-21 15:15:59 -05:00
Julian Lam
eaba2aa37f feat: temporarily deny handling non-public notes 2024-02-21 14:58:52 -05:00
Julian Lam
49c69a0f6a feat: shared inbox 2024-02-21 14:44:23 -05:00
Julian Lam
175521ba67 test: fix well-known error code repsonse 2024-02-21 14:37:21 -05:00
Julian Lam
92a8951bca fix: check origin only if object is a string 2024-02-21 14:05:54 -05:00
Julian Lam
a94341f489 feat: security, cross-check key ownership against received actor 2024-02-21 13:43:56 -05:00
Julian Lam
ed4ccbfccc refactor: resolveId method, add hostname verification 2024-02-21 10:58:20 -05:00
Julian Lam
42a0924137 test: refactor AP tests 2024-02-21 10:26:26 -05:00
Julian Lam
e138b915b9 test: fix tests 2024-02-20 15:01:38 -05:00
Julian Lam
3e02efcdb9 lint: unused requires, linting fixes 2024-02-20 14:26:21 -05:00
Julian Lam
893dd523ca fix: send string to validator 2024-02-20 14:23:53 -05:00
Julian Lam
53d1c10ec4 fix: regression re: attachment handling 2024-02-20 14:19:50 -05:00
Julian Lam
31fd135f6e fix: parseIntFields for tids 2024-02-20 14:09:06 -05:00
Julian Lam
cfbbf4ca86 fix: handle undo(Announce) of remote notes 2024-02-20 13:43:45 -05:00
Julian Lam
d77de088e6 Merge remote-tracking branch 'origin/develop' into activitypub 2024-02-20 12:08:53 -05:00
Julian Lam
6a7f8f146f fix: assert actors on note assertion, resolve crash if announcing something that's already been announced 2024-02-20 11:57:50 -05:00
Julian Lam
0edaf17c6a fix: don't crash when no attachment, oops 2024-02-16 15:59:04 -05:00
Julian Lam
e5b6ee97bd feat: attachments support 2024-02-16 12:07:29 -05:00
Julian Lam
8b0ccc8090 fix: sanitize post remote post content regardless 2024-02-14 22:53:27 -05:00
Julian Lam
068c4fa90e fix: wrong id parsed in announce 2024-02-14 22:53:14 -05:00
Julian Lam
56cfae0766 fix: accidental overwrite of hoisted internal variable 2024-02-14 11:56:21 -05:00
Julian Lam
5c04e8051c fix: include mainPid in topic counts calculation 2024-02-14 11:38:31 -05:00
Julian Lam
19d017b942 fix: guests visiting /world should see all remote topics 2024-02-14 10:44:13 -05:00
Julian Lam
6a11c89b90 fix: timestamp calculation 2024-02-14 10:23:06 -05:00
Julian Lam
76d5feb7d6 fix: better handling of missing of invalid to, cc, timestamp values 2024-02-13 12:03:16 -05:00
Julian Lam
814c479405 fix: remote announces not showing up in local inboxes 2024-02-12 16:23:21 -05:00
Julian Lam
9439987eda fix: handle inaccessible boosts 2024-02-12 15:25:49 -05:00
Julian Lam
672a907d54 fix: remove invalid/inaccessible object from parent traversal chain on get error 2024-02-12 15:05:18 -05:00
Julian Lam
609a9a37e5 fix: handle invalid score 2024-02-12 15:01:10 -05:00
Julian Lam
22da3a6a80 fix: mainPid accidental int 2024-02-12 14:59:13 -05:00
Julian Lam
69f1ace3e5 fix: getParentChain error handling 2024-02-12 14:54:13 -05:00
Julian Lam
d13980c8fb fix: take mainPid into account in syncUserInboxes 2024-02-12 14:51:21 -05:00
Julian Lam
de71b733f8 fix: typo 2024-02-12 14:41:22 -05:00
Julian Lam
8912863423 feat: save tids to individual user inboxes based on recipient list, new /world/all route 2024-02-12 14:34:37 -05:00
Julian Lam
da085b0ece feat: save actor follower URL backreference and sorted set backreference 2024-02-12 14:32:55 -05:00
Julian Lam
401b4c5fa6 fix: stringify error body in ap.send 2024-02-09 11:37:22 -05:00
Julian Lam
12968b6291 fix: activitypub.get failure handling 2024-02-09 11:31:42 -05:00
Julian Lam
ce4b5679a2 fix: only log warning on ap.send failure 2024-02-09 11:24:03 -05:00
Julian Lam
5fee4e3306 fix: rejig some tertiary conditionals, fix broken inReplyTo when toPid is not defined and mainPid is a remote post 2024-02-09 11:18:23 -05:00
Julian Lam
11dba85d0a fix: resolveLocalId to return null values instead of throwing for no resolution 2024-02-09 11:15:03 -05:00
Julian Lam
ac56289fa2 fix: don't crash on activitypub.send non-2xx, better logging for successful sends 2024-02-08 21:34:26 -05:00
Julian Lam
874b24cbcf feat: infinite scrolling for /world 2024-02-08 12:29:20 -05:00
Julian Lam
2b1a347792 fix: replies to remote content overriding mainPid in topic 2024-02-08 11:55:48 -05:00
Julian Lam
f1b8f3fe4f fix: assertTopic incorrectly creating new id if passed-in id did not match the actual object's id 2024-02-08 11:33:27 -05:00
Julian Lam
944de4ea26 Merge remote-tracking branch 'origin/develop' into activitypub 2024-02-07 15:07:42 -05:00
Julian Lam
4fb6574cf7 fix: urls in posts and announces 2024-02-07 14:29:47 -05:00
Julian Lam
a61e7fe79b fix: remove now unused world/:tid route and assoc. controller 2024-02-07 13:49:14 -05:00
Julian Lam
08f8babd9f debug: additional logging 2024-02-07 12:50:26 -05:00
Julian Lam
aafdebd211 fix: cast cids passed in to getCategoriesFields as ints 2024-02-07 12:40:59 -05:00
Julian Lam
80377599d1 fix: logic error 2024-02-07 12:38:46 -05:00
Julian Lam
6508287db5 feat: record remote Announce(Note) 2024-02-07 12:28:27 -05:00
Julian Lam
ec2b375ae9 refactor: use existing sets for topics (instead of topicRemote, etc.) 2024-02-07 12:28:16 -05:00
Julian Lam
94dcd29e63 feat: handle Announce(Note) when Note is a piece of remote content 2024-02-07 00:14:29 -05:00
Julian Lam
9795abbf58 fix: logic derp 2024-02-06 15:20:30 -05:00
Julian Lam
00efbd6dd9 Merge remote-tracking branch 'origin/develop' into activitypub 2024-02-06 14:58:06 -05:00
Julian Lam
415b4fe11a feat: Announce(Note) and Undo(Announce) 2024-02-06 14:57:44 -05:00
Julian Lam
21a2876e9c fix: new language source file for activitypub text 2024-02-06 14:57:23 -05:00
Julian Lam
b9821c5206 feat: exposed method to find topic events based on a subset of saved info, returns topic event ids 2024-02-06 14:56:34 -05:00
Julian Lam
f7259ccb8b fix: getUserInfo internal method in topic events to not filter out uids because getUsersFields already does it 2024-02-06 14:56:01 -05:00
Julian Lam
a461e5dd41 feat: topic and category S2S get responses 2024-02-06 11:04:08 -05:00
Julian Lam
25f0d48432 fix: category following and acceptance logic 2024-02-06 10:40:46 -05:00
Julian Lam
35819cc953 refactor: activitypub sending to handle signed requests from categories 2024-02-05 16:57:17 -05:00
Julian Lam
42bb3c3399 test: fix tests to reflect new immutable IDs, and added new tests for resource retrieval 2024-02-05 14:12:23 -05:00
Julian Lam
d2f14c363d fix: properly assert resources in AP router 2024-02-05 14:12:00 -05:00
Julian Lam
d18e65c473 feat: send proper content-type on AP S2S responses 2024-02-05 14:11:32 -05:00
Julian Lam
d213ce790c fix: update preferredUsername in category actor to match webfinger representation 2024-02-05 10:09:44 -05:00
Julian Lam
88733a5160 feat: category actors, stub outbox 2024-02-02 17:19:59 -05:00
Julian Lam
ae042ce39e feat: returning following and follower collections upon request 2024-02-01 16:53:47 -05:00
Julian Lam
607c4623c7 feat: Like(Note) and Undo(Like); federating likes 2024-02-01 16:05:31 -05:00
Julian Lam
94361721b1 fix: follower calls to return empty item list unless a page is explicitly passed in 2024-01-30 12:11:33 -05:00
Julian Lam
6930973d7a feat: origin checking on received Update activities 2024-01-30 12:11:10 -05:00
Julian Lam
0e59f3124e feat: Update(Note) 2024-01-30 11:25:45 -05:00
Julian Lam
86a607ce8c fix: move all actor object urls to immutable variants 2024-01-29 16:59:13 -05:00
Julian Lam
457bfe1685 fix: send proper @context value for posts (was missing) 2024-01-29 16:33:41 -05:00
Julian Lam
fef1882473 fix: remote follows, yet again 2024-01-26 22:35:02 -05:00
Julian Lam
a3a0edb70b fix: a bunch of broken things, added test for Create(Note) 2024-01-26 21:39:20 -05:00
Julian Lam
6e87cf57a2 feat: Update(Person) 2024-01-26 16:48:16 -05:00
Julian Lam
fa1c549002 fix: error handling for actor.assert 2024-01-26 16:24:14 -05:00
Julian Lam
ef8cd34ba1 refactor: replace JIT actor retrieval with actor assertion and storage logic 2024-01-26 15:10:35 -05:00
Julian Lam
2c8342632f fix: missing break statement 2024-01-26 11:45:09 -05:00
Julian Lam
a355c05d83 fix: reject spiders on remote profile GETs 2024-01-26 11:36:44 -05:00
Julian Lam
25434d2a6e fix: send back correct actor id on webfinger calls 2024-01-26 11:20:14 -05:00
Julian Lam
ddf17ee5fb fix: parseInt on timestamp from post 2024-01-25 16:33:31 -05:00
Julian Lam
6b517252b9 feat: activitypub response to note retrieval via pid 2024-01-25 16:27:56 -05:00
Julian Lam
0b3ca8e366 fix: /uid/:uid route to return actor 2024-01-25 16:00:46 -05:00
Julian Lam
5de4f08412 refactor: added mocks.note in preparation for AP note retrieval logic, inReplyTo is always populated now, unless new topic 2024-01-25 15:38:01 -05:00
Julian Lam
e07c31316e fix: the id of an actor should be its immutable uri (/uid/:uid) and not userslug 2024-01-25 14:23:59 -05:00
Julian Lam
3c24cfb4a9 fix: unused require, tests 2024-01-25 11:06:30 -05:00
Julian Lam
0af97dd444 fix: send full as:Image object for icon and image in mocked actor object 2024-01-24 22:59:09 -05:00
Julian Lam
8453b83e9e feat: added mocks.actor and Update(Person) activity on profile update 2024-01-24 20:10:22 -05:00
Julian Lam
f51bbe91e7 fix: clear post cache on Update(Note) 2024-01-24 14:35:21 -05:00
Julian Lam
1ee03f2b83 fix: webfinger loopback 2024-01-24 14:14:24 -05:00
Julian Lam
e00a03bb9c fix: revert home page routing change and moved instance actor endpoint to 2024-01-24 14:09:40 -05:00
Julian Lam
911177ceda chore: removed unused requires 2024-01-24 14:01:40 -05:00
Julian Lam
0e016c6ecd feat: Create(Note) on new topic or reply
This is a naive WIP implementation that federates everything out publicly. It does not take category privileges into account!
2024-01-24 11:44:10 -05:00
Julian Lam
c9feb92539 refactor: simplify remote (un)follow controller 2024-01-23 12:11:35 -05:00
Julian Lam
3bdaa8a836 fix: restore avatars in topic list 2024-01-23 11:49:57 -05:00
Julian Lam
da0211b1a0 feat: proper webfinger response for instance actor 2024-01-23 11:22:18 -05:00
Julian Lam
f8cfe64c7e fix: send preferredUsername in application actor response 2024-01-23 10:31:52 -05:00
Julian Lam
4cc7ee6501 fix: note assertion on Create(Note) and Update(Note) to use instance key instead of uid 1's user key 2024-01-23 10:31:13 -05:00
Julian Lam
92c990f2f7 chore: remove debug log 2024-01-23 10:16:44 -05:00
Opliko
2ce14f5019 refactor: guard dispatcher definition so non-Node runtimes won't have issues 2024-01-22 18:05:27 -05:00
Opliko
2cb370882b fix: add workaround for nodejs/undici#1305 required to remove sec-fetch-mode header 2024-01-22 18:05:27 -05:00
Julian Lam
9885f94a2b feat: application actor + public key for uid 0 signs, moved homepage route to after core routes are generated 2024-01-22 16:18:49 -05:00
Julian Lam
403bf3e1a8 chore: remove debug log 2024-01-22 14:46:38 -05:00
Julian Lam
9e58b04c4e fix: response, not res 2024-01-22 14:06:39 -05:00
Julian Lam
47b3ffb9b1 lint: fix eslint errors 2024-01-22 13:51:06 -05:00
Julian Lam
76c6e30282 Merge remote-tracking branch 'origin/develop' into activitypub 2024-01-22 13:50:08 -05:00
Julian Lam
a3e1a666b8 fix: automatically reject unsigned POSTs to inbox 2024-01-19 11:43:21 -05:00
Julian Lam
2ff70fdde2 refactor: generate a topic id when asserting a new topic 2024-01-19 11:31:04 -05:00
Julian Lam
981b4f146d feat: native parsing of title for topics 2024-01-18 16:20:37 -05:00
Julian Lam
33f3da8a64 fix: handle case where pubKey cannot be found 2024-01-18 15:21:46 -05:00
Julian Lam
518169fe65 fix: user data in /world 2024-01-18 12:19:37 -05:00
Julian Lam
538776f9c7 refactor: move activitypub-related middlewares to their own file 2024-01-18 11:50:14 -05:00
Julian Lam
04423232c6 feat: beginnings of the /world route 2024-01-17 23:14:12 -05:00
Julian Lam
970a5a6e5f fix: bookmark in topic data is nullable now
This prevents the superfluous "/1" being appended to all topics you've never been into.

This looks to be a change in the public API but the documented API lists this property as nullable already, so it looks like this commit is actually *restoring* proper behaviour.
2024-01-17 23:13:19 -05:00
Julian Lam
60bc27ec69 fix: saving teaserPid, timestamp, lastposttime in topic, fixed post index issue in loading topics 2024-01-17 12:15:58 -05:00
Julian Lam
e038eb0509 fix: preserve cid if set 2024-01-17 11:54:20 -05:00
Julian Lam
9c15b02aa6 chore: update log verbiage 2024-01-17 11:47:57 -05:00
Julian Lam
ac672f08a7 feat: handle Update(note) 2024-01-16 13:55:58 -05:00
Julian Lam
641a94d64a chore: additional logging 2024-01-16 12:04:26 -05:00
Julian Lam
f74775365a feat: handle Create(note) 2024-01-16 12:00:50 -05:00
Julian Lam
7565485204 fix: incorrect author uid saved in topic 2024-01-16 12:00:40 -05:00
Julian Lam
8cd3ff1ab4 fix: infinite scroll for posts 2024-01-16 11:42:59 -05:00
Julian Lam
5ac0276530 fix: reply expansion 2024-01-16 11:20:54 -05:00
Julian Lam
28c3dcb626 Merge remote-tracking branch 'origin/develop' into activitypub 2024-01-16 10:45:26 -05:00
Julian Lam
2981f663ce fix: issues related to adding new reply chains to an existing topic, resolveId method in notes module 2024-01-16 10:44:47 -05:00
Julian Lam
7e89eadb36 feat: flesh out more topic info, fix navigator 2024-01-13 22:27:02 -05:00
Julian Lam
81d810983a fix: simplify increasePostCount logic by combining methods 2024-01-13 22:26:39 -05:00
Julian Lam
4bd7a574c3 feat: some additional work to properly save and retrieve remote topic data 2024-01-12 16:39:29 -05:00
Julian Lam
273188632d Merge remote-tracking branch 'origin/develop' into activitypub 2024-01-12 15:23:41 -05:00
Julian Lam
485cf20006 feat: ability to browse to any ActivityPub note and have the entire topic chain render
Added methods for going up the inReplyTo chain to parent, asserting the topic, etc.
2024-01-12 15:23:30 -05:00
Julian Lam
d992239d7b fix: have mocks.posts return same number of items as received 2024-01-12 11:29:08 -05:00
Julian Lam
326bb995b1 feat: add activitypub request cache 2024-01-12 11:27:55 -05:00
Julian Lam
2b3b6e56af chore: move assertNote to separate file in preparation for additional note-based methods 2024-01-11 10:05:02 -05:00
Julian Lam
ab40ba6167 feat: assertNotes method 2024-01-10 20:52:38 -05:00
Julian Lam
3ce84b39bc feat: ability to load remote post data in a topic 2024-01-10 20:52:28 -05:00
Julian Lam
2bd9e9847d refactor: simplify mocks.post as it only needs minimal data for saving into db 2024-01-10 20:51:23 -05:00
Julian Lam
0d478b2c76 feat: plumb uid into .addParentPosts() so .assertNotes() can be properly called 2024-01-10 20:49:27 -05:00
Julian Lam
06e1583461 refactor: move mockProfile and mockPost to separate mocks.js 2024-01-10 14:19:57 -05:00
Julian Lam
f1e5e5a0ad feat: stub lib for retrieving and mocking posts 2024-01-10 14:14:44 -05:00
Julian Lam
91a509c4eb test: fix error copy 2024-01-08 15:03:46 -05:00
Julian Lam
d1cdb8f858 fix: show full names for remote users 2024-01-08 15:02:36 -05:00
Julian Lam
5e1e809962 feat: send name property in getActor response 2024-01-08 14:46:43 -05:00
Julian Lam
9221506b0f fix: improper params sent to get and getActor 2024-01-08 14:46:33 -05:00
Julian Lam
ec3c6b67cd fix: restore hostname prop in getActor method 2024-01-08 14:45:56 -05:00
Julian Lam
7ec75f5830 fix: ActivityPub.get now throws on failure, handle in getActor 2024-01-08 14:30:09 -05:00
Julian Lam
a1c8c3a043 refactor: update ActivityPub.get and all methods calling it to take a uid so that requests can be signed 2024-01-05 22:46:07 -05:00
Julian Lam
942a9b7e5c fix: send in mocked profile data in follow routes as well 2024-01-05 15:39:45 -05:00
Julian Lam
2a77ae550b feat: ability to browse follower/following lists of remote accounts 2024-01-05 15:32:55 -05:00
Julian Lam
c66b18670d fix: tests 2024-01-05 11:56:20 -05:00
Julian Lam
7fd15d6668 feat: expose following/follower counts in actor response 2024-01-05 11:39:17 -05:00
Julian Lam
27e256d26e fix: isUri helper so that it passes ci tests 2024-01-05 11:38:26 -05:00
Julian Lam
fcca9259b4 fix: frontend follow logic, as uid is no longer webfinger slug 2024-01-05 10:18:44 -05:00
Julian Lam
283e460a57 fix: use simpler getSortedSetRevRange instead of unnecessary zunion call
/cc @barisusakli
2024-01-05 10:04:59 -05:00
Julian Lam
e4eeb069c2 Merge remote-tracking branch 'origin/develop' into activitypub 2024-01-05 09:58:46 -05:00
Julian Lam
739b05481c feat: show remote followers/following in user profile pages 2024-01-05 09:58:37 -05:00
Julian Lam
672c70146a feat: update mockProfile to accept actor uris as well as actor objects 2024-01-04 16:25:46 -05:00
Julian Lam
2e330d8b3a refactor: validator check to helper method 2024-01-04 16:23:09 -05:00
Julian Lam
8d4fd9c0f8 refactor: move profile mocking logic to discrete method in main activitypub lib 2024-01-04 14:04:34 -05:00
Julian Lam
759d69e06c fix: accept and undo logic saving improper id into database, updated follow logic so remote follow is not added to collection until an accept is received 2024-01-03 13:54:17 -05:00
Julian Lam
ccbf32bcc5 Merge remote-tracking branch 'origin/develop' into activitypub 2024-01-02 11:58:25 -05:00
Julian Lam
3339baac18 fix: relax activity-failed condition 2023-12-22 16:02:27 -05:00
Julian Lam
b93a9eec7e Merge remote-tracking branch 'origin/develop' into activitypub 2023-12-22 15:53:30 -05:00
Julian Lam
6a54e0090b fix: additional refactors and updates to follow/unfollow logic 2023-12-22 15:53:04 -05:00
Julian Lam
ac3440114a fix: bug where body wasn't properly sent on ap-style content-types 2023-12-22 15:52:38 -05:00
Julian Lam
543daee866 fix: send actor uris in follow instead of webfinger ids 2023-12-22 13:56:18 -05:00
Julian Lam
f3b0794d17 fix: some wip code regarding handling a follow activity, remove unfollow activity as that does not exist 2023-12-22 13:35:09 -05:00
Julian Lam
0eadad84cd fix: accidental double-hash in sign/verify 2023-12-22 12:58:46 -05:00
Julian Lam
71e3d26bb4 fix: incorrect logic 2023-12-22 12:58:30 -05:00
Julian Lam
dd480aa4f6 fix: update proceedOnActivityPub middleware to handle how Mastodon doesn't send the 'Accepts' header 2023-12-21 15:49:51 -05:00
Julian Lam
885278842b fix: another bad call to request lib 2023-12-21 14:46:03 -05:00
Julian Lam
8d79617044 fix: broken call to request lib, tests 2023-12-21 14:38:16 -05:00
Julian Lam
00bda0688d Merge remote-tracking branch 'origin/develop' into activitypub 2023-12-21 14:23:50 -05:00
Julian Lam
5c881d3f51 refactor: update ap libs to use core request lib 2023-12-19 14:33:38 -05:00
Julian Lam
510763af30 fix: update isJSON test in request lib 2023-12-19 14:33:14 -05:00
Julian Lam
0d6f0530f5 Merge remote-tracking branch 'origin/develop' into activitypub 2023-12-19 14:02:33 -05:00
Julian Lam
a21110fd88 fix: handle null actor uri in helpers.query 2023-12-14 13:47:28 -05:00
Julian Lam
24c1dfac8c test: allow http proto on ci 2023-12-14 12:25:06 -05:00
Julian Lam
48fd2e6441 Merge remote-tracking branch 'origin/develop' into activitypub 2023-12-13 13:21:32 -05:00
Julian Lam
7f46f07cb9 fix: unused require 2023-12-13 13:21:29 -05:00
Julian Lam
4c1b2b3fe6 feat: accept and undo support 2023-12-13 13:15:03 -05:00
Julian Lam
68d5e4a8ab refactor: update activitypub.getActor to accept either url or webfinger id 2023-12-13 13:14:51 -05:00
Julian Lam
1f79f54241 feat: update activitypub helper resolveLocalUid to accept both webfinger name and full URL as input 2023-12-11 14:35:04 -05:00
Julian Lam
c803b2124c refactor: minor restructure to move logic out of main controller file to src/api 2023-12-08 10:55:16 -05:00
Julian Lam
8a5fb86ddf chore: small var rename 2023-12-08 10:46:34 -05:00
Julian Lam
e794f1d2ce fix: store remote followed users count separately from local 2023-12-07 13:23:06 -05:00
Julian Lam
4324f09c85 fix: icon text and bgColor in remote profiles 2023-12-07 13:10:06 -05:00
Julian Lam
5e693702a4 chore: minor re-shuffling of code 2023-12-07 12:36:30 -05:00
Julian Lam
99cc60c8d5 fix: add basic sanity-checking to middleware.validateActivity 2023-12-06 13:57:49 -05:00
Julian Lam
cc0d18869a test: fixed improper signed_string reconstruction in .verify() 2023-12-06 13:38:41 -05:00
Julian Lam
bcee1c8dc8 fix: incorrect host/hostname usage in well-known test 2023-12-06 13:38:41 -05:00
Julian Lam
4218ecc4a0 fix: save remote follower count separately from local follower count 2023-12-06 13:38:41 -05:00
Julian Lam
c02271c7af feat: follow/unfollow logic and receipt 2023-12-06 13:38:41 -05:00
Julian Lam
e6753ce5db fix: missing req.body when parsing ActivityPub requests 2023-12-06 13:38:41 -05:00
Julian Lam
9dfa1b7209 test: fix webfinger test 2023-12-06 13:38:41 -05:00
Julian Lam
5d95765ee7 fix: bugs, more prep to start making calls to self 2023-12-06 13:38:41 -05:00
Julian Lam
9f94653b3f style: remove unused variable 2023-12-06 13:38:41 -05:00
Julian Lam
cdc4275fec feat: actor cache, method to resolve inboxes, stub code for sending requests. Now base64 encoding digest as expected by Mastodon 2023-12-06 13:38:41 -05:00
Julian Lam
2e89900886 chore: reorganize controllers for clarity 2023-12-06 13:38:41 -05:00
Julian Lam
a10df9873b test: added passing test cases for .sign() and .verify() 2023-12-06 13:38:41 -05:00
Julian Lam
e7184eb8cc feat: http signatures support, .sign() and .verify() AP helper methods 2023-12-06 13:38:41 -05:00
Julian Lam
4f5f025d57 feat: add webfinger ttl cache 2023-12-06 13:38:41 -05:00
Julian Lam
57895b7246 feat: add .has() call to cache/ttl 2023-12-06 13:38:39 -05:00
Julian Lam
ab3ff320b5 refactor: acp tpl + config option
- Updated ACP template to match new format
- changed global switch to `activitypubEnabled` (lowercase p)
2023-12-06 13:38:18 -05:00
Julian Lam
0cbbce8c16 chore: update AP helpers export, 404 logic reversal, no slugify in userslug in mock profile from remote instance 2023-12-06 13:38:18 -05:00
Julian Lam
a05b674e27 feat: ability to view federated profiles via url manipulation 2023-12-06 13:38:18 -05:00
Julian Lam
7e1dac39ea feat: followers and following endpoints 2023-12-06 13:38:18 -05:00
Julian Lam
81b6260f2e feat: inbox and outbox routes, stub controllers 2023-12-06 13:38:18 -05:00
Julian Lam
099124c49e feat: global switch for disabling federation, + test 2023-12-06 13:38:18 -05:00
Julian Lam
1c8e13bb12 test: updated activitypub test suite 2023-12-06 13:38:18 -05:00
Julian Lam
4bd8d28a8b test: added test cases for activitypub integration, WIP 2023-12-06 13:38:18 -05:00
Julian Lam
2dec357aee feat: activitypub actor endpoint for user accounts 2023-12-06 13:38:18 -05:00
Julian Lam
51d8f3b195 fix: moved .well-known assets to separate router file, added basic webfinger implementation
added tests for webfinger controller
2023-12-06 13:38:18 -05:00
210 changed files with 7734 additions and 418 deletions

View File

@@ -190,5 +190,10 @@
"composer:allowPluginHelp": 1,
"maxReconnectionAttempts": 5,
"reconnectionDelay": 1500,
"disableCustomUserSkins": 0
"disableCustomUserSkins": 0,
"activitypubEnabled": 1,
"activitypubAllowLoopback": 0,
"activitypubContentPruneDays": 30,
"activitypubUserPruneDays": 7,
"activitypubFilter": 0
}

View File

@@ -41,6 +41,14 @@
"textClass": "d-lg-none",
"text": "[[global:header.popular]]"
},
{
"route": "/world",
"title": "[[global:header.world]]",
"enabled": true,
"iconClass": "fa-globe",
"textClass": "d-lg-none",
"text": "[[global:header.world]]"
},
{
"route": "/users",
"title": "[[global:header.users]]",

View File

@@ -2,7 +2,7 @@
"name": "nodebb",
"license": "GPL-3.0",
"description": "NodeBB Forum",
"version": "3.9.0",
"version": "4.0.0-beta.1",
"homepage": "https://www.nodebb.org",
"repository": {
"type": "git",
@@ -50,6 +50,7 @@
"bootswatch": "5.3.3",
"chalk": "4.1.2",
"chart.js": "4.4.4",
"cheerio": "^1.0.0-rc.12",
"cli-graph": "3.2.2",
"clipboard": "2.0.11",
"colors": "1.4.0",
@@ -102,15 +103,15 @@
"nodebb-plugin-dbsearch": "6.2.5",
"nodebb-plugin-emoji": "5.1.15",
"nodebb-plugin-emoji-android": "4.0.0",
"nodebb-plugin-markdown": "12.2.8",
"nodebb-plugin-mentions": "4.4.3",
"nodebb-plugin-ntfy": "1.7.7",
"nodebb-plugin-markdown": "13.0.0-pre.9",
"nodebb-plugin-mentions": "4.6.8",
"nodebb-plugin-spam-be-gone": "2.2.2",
"nodebb-plugin-web-push": "0.6.0",
"nodebb-rewards-essentials": "1.0.0",
"nodebb-theme-harmony": "1.2.70",
"nodebb-theme-harmony": "2.0.0-pre.36",
"nodebb-theme-lavender": "7.1.8",
"nodebb-theme-peace": "2.2.7",
"nodebb-theme-persona": "13.3.37",
"nodebb-theme-persona": "14.0.0-pre.4",
"nodebb-widget-essentials": "7.0.29",
"nodemailer": "6.9.15",
"nprogress": "0.2.0",
@@ -198,4 +199,4 @@
"url": "https://github.com/barisusakli"
}
]
}
}

View File

@@ -0,0 +1,18 @@
{
"world.name": "World",
"world.description": "",
"world.popular": "Popular topics",
"world.recent": "All topics",
"world.help": "Help",
"no-topics": "This forum doesn't know of any other topics yet.",
"help.title": "What is this page?",
"help.intro": "Welcome to your corner of the fediverse.",
"help.fediverse": "The \"fediverse\" is a network of interconnected applications and websites that all talk to one another and whose users can see each other. This forum is federated, and can interact with that social web (or \"fediverse\"). This page is your corner of the fediverse. It consists solely of topics created by — and shared from — users <strong>you</strong> follow.",
"help.build": "There might not be a lot of topics here to start; that's normal. You will start to see more content here over time when you start following other users.",
"help.federating": "Likewise, if users from outside of this forum start following <em>you</em>, then your posts will start appearing on those apps and websites as well.",
"help.next-generation": "This is the next generation of social media, start contributing today!",
"announcers": "Shares",
"announcers-x": "Shares (%1)"
}

View File

@@ -7,6 +7,8 @@
"privileges": "Privileges",
"back-to-categories": "Back to categories",
"name": "Category Name",
"handle": "Category Handle",
"handle.help": "Your category handle is used as a representation of this category across other networks, similar to a username. A category handle must not match an existing username or user group.",
"description": "Category Description",
"bg-color": "Background Colour",
"text-color": "Text Colour",
@@ -37,6 +39,7 @@
"disable": "Disable",
"edit": "Edit",
"analytics": "Analytics",
"federation": "Federation",
"view-category": "View category",
"set-order": "Set order",
@@ -76,6 +79,22 @@
"analytics.topics-daily": "<strong>Figure 3</strong> &ndash; Daily topics created in this category</small>",
"analytics.posts-daily": "<strong>Figure 4</strong> &ndash; 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 &rarr;",
"federation.syncing-header": "Synchronization",
"federation.syncing-intro": "A category can follow a \"Group Actor\" via the ActivityPub protocol. If content is received from one of the actors listed below, it will be automatically added to this category.",
"federation.syncing-caveat": "N.B. Setting up syncing here establishes a one-way synchronization. NodeBB attempts to subscribe/follow the actor, but the reverse cannot be assumed.",
"federation.syncing-none": "This category is not currently following anybody.",
"federation.syncing-add": "Synchronize with...",
"federation.syncing-actorUri": "Actor",
"federation.syncing-follow": "Follow",
"federation.syncing-unfollow": "Unfollow",
"federation.followers": "Remote users following this category",
"federation.followers-handle": "Handle",
"federation.followers-id": "ID",
"federation.followers-none": "No followers.",
"alert.created": "Created",
"alert.create-success": "Category successfully created!",
"alert.none-active": "You have no active categories.",

View File

@@ -38,6 +38,7 @@
"settings/tags": "Tags",
"settings/notifications": "Notifications",
"settings/api": "API Access",
"settings/activitypub": "Federation (ActivityPub)",
"settings/sounds": "Sounds",
"settings/social": "Social",
"settings/cookies": "Cookies",

View 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"
}

View File

@@ -265,6 +265,7 @@
"topic-event-unrecognized": "Topic event '%1' unrecognized",
"category.handle-taken": "Category handle is already taken, please choose another.",
"cant-set-child-as-parent": "Can't set child as parent category",
"cant-set-self-as-parent": "Can't set self as parent category",
@@ -278,5 +279,12 @@
"api.500": "An unexpected error was encountered while attempting to service your request.",
"api.501": "The route you are trying to call is not implemented yet, please try again tomorrow",
"api.503": "The route you are trying to call is not currently available due to a server configuration",
"api.reauth-required": "The resource you are trying to access requires (re-)authentication."
"api.reauth-required": "The resource you are trying to access requires (re-)authentication.",
"activitypub.invalid-id": "Unable to resolve the input id, likely as it is malformed.",
"activitypub.get-failed": "Unable to retrieve the specified resource.",
"activitypub.pubKey-not-found": "Unable to resolve public key, so payload verification cannot take place.",
"activitypub.origin-mismatch": "The received object's origin does not match the sender's origin",
"activitypub.actor-mismatch": "The received activity is being carried out by an actor that is different from expected.",
"activitypub.not-implemented": "The request was denied because it or an aspect of it is not implemented by the recipient server"
}

View File

@@ -84,11 +84,17 @@
"modal-reason-offensive": "Offensive",
"modal-reason-other": "Other (specify below)",
"modal-reason-custom": "Reason for reporting this content...",
"modal-notify-remote": "Forward this report to %1",
"modal-submit": "Submit Report",
"modal-submit-success": "Content has been flagged for moderation.",
"modal-confirm-rescind": "Rescind Report?",
"bulk-actions": "Bulk Actions",
"bulk-resolve": "Resolve Flag(s)",
"confirm-purge": "Are you sure you want to permanently delete these flags?",
"purge-cancelled": "Flag Purge Cancelled",
"bulk-purge": "Purge Flag(s)",
"bulk-success": "%1 flags updated",
"flagged-timeago": "Flagged <span class=\"timeago\" title=\"%1\"></span>",
"auto-flagged": "[Auto Flagged] Received %1 downvotes."

View File

@@ -56,6 +56,7 @@
"header.navigation": "Navigation",
"header.manage": "Manage",
"header.drafts": "Drafts",
"header.world": "World",
"notifications.loading": "Loading Notifications",
"chats.loading": "Loading Chats",
@@ -131,6 +132,8 @@
"invisible": "Invisible",
"offline": "Offline",
"remote-user": "This user is from outside of this forum",
"email": "Email",
"language": "Language",

View File

@@ -117,6 +117,8 @@
"composer.discard-all-drafts": "Discard all drafts",
"composer.no-drafts": "You have no drafts",
"composer.discard-draft-confirm": "Do you want to discard this draft?",
"composer.remote-pid-editing": "Editing a remote post",
"composer.remote-pid-content-immutable": "The content of remote posts cannot be edited. However, you are able change the topic title and tags.",
"bootbox.ok": "OK",
"bootbox.cancel": "Cancel",

View File

@@ -107,5 +107,10 @@
"notificationType-post-queue": "When a new post is queued",
"notificationType-new-post-flag": "When a post is flagged",
"notificationType-new-user-flag": "When a user is flagged",
"notificationType-new-reward": "When you earn a new reward"
"notificationType-new-reward": "When you earn a new reward",
"activitypub.announce": "<strong>%1</strong> shared your post in <strong>%2</strong> to their followers.",
"activitypub.announce-dual": "<strong>%1</strong> and <strong>%2</strong> shared your post in <strong>%3</strong> to their followers.",
"activitypub.announce-triple": "<strong>%1</strong>, <strong>%2</strong> and <strong>%3</strong> shared your post in <strong>%4</strong> to their followers.",
"activitypub.announce-multiple": "<strong>%1</strong>, <strong>%2</strong> and %3 others shared your post in <strong>%4</strong> to their followers."
}

View File

@@ -42,6 +42,8 @@
"flags": "Flags",
"flag-details": "Flag %1 Details",
"world": "World",
"account/edit": "Editing \"%1\"",
"account/edit/password": "Editing password of \"%1\"",
"account/edit/username": "Editing username of \"%1\"",

View File

@@ -152,6 +152,7 @@
"bookmarks.has-no-bookmarks": "You haven't bookmarked any posts yet.",
"copy-permalink": "Copy Permalink",
"go-to-original": "View Original Post",
"loading-more-posts": "Loading More Posts",
"move-topic": "Move Topic",

View File

@@ -60,6 +60,7 @@
"chat-with": "Continue chat with %1",
"new-chat-with": "Start new chat with %1",
"flag-profile": "Flag Profile",
"profile-flagged": "Already flagged",
"follow": "Follow",
"unfollow": "Unfollow",
"more": "More",

View File

@@ -8,6 +8,14 @@ CategoryObject:
name:
type: string
description: The category's name/title
handle:
type: string
description: |
An URL-safe name/handle used to represent the category over federated networks (e.g. ActivityPub).
This value is separate from the `slug`, which is used specifically in the URL as a human-readable representation.
The handle is unique across-the-board between users/groups/categories.
description:
type: string
description: A variable-length description of the category (usually displayed underneath the category name)

View File

@@ -61,6 +61,9 @@ MessageObject:
uid:
type: number
description: A user identifier
isLocal:
type: boolean
description: Whether the user belongs to the local installation or not.
username:
type: string
description: A friendly name for a given user account
@@ -122,6 +125,9 @@ RoomUserList:
uid:
type: number
description: A user identifier
isLocal:
type: boolean
description: Whether the user belongs to the local installation or not.
username:
type: string
description: A friendly name for a given user account

View File

@@ -58,6 +58,9 @@ FlagObject:
uid:
type: number
description: A user identifier
isLocal:
type: boolean
description: Whether the user belongs to the local installation or not.
icon:text:
type: string
description: A single-letter representation of a username. This is used in the
@@ -122,6 +125,9 @@ FlagHistoryObject:
uid:
type: number
description: A user identifier
isLocal:
type: boolean
description: Whether the user belongs to the local installation or not.
icon:text:
type: string
description: A single-letter representation of a username. This is used in the
@@ -175,6 +181,9 @@ FlagNotesObject:
uid:
type: number
description: A user identifier
isLocal:
type: boolean
description: Whether the user belongs to the local installation or not.
icon:text:
type: string
description: A single-letter representation of a username. This is used in the

View File

@@ -7,6 +7,15 @@ PostObject:
tid:
type: number
description: A topic identifier
toPid:
type: number
description: The post that this post is in reply to
nullable: true
url:
type: string
description: |
A permalink to the post content.
For posts received via ActivityPub, it is the url of the original piece of content.
content:
type: string
uid:
@@ -31,6 +40,9 @@ PostObject:
uid:
type: number
description: A user identifier
isLocal:
type: boolean
description: Whether the user belongs to the local installation or not.
username:
type: string
description: A friendly name for a given user account

View File

@@ -35,6 +35,9 @@ TopicObject:
uid:
type: number
description: A user identifier
isLocal:
type: boolean
description: Whether the user belongs to the local installation or not.
username:
type: string
description: A friendly name for a given user account

View File

@@ -5,6 +5,9 @@ UserObject:
type: number
description: A user identifier
example: 1
isLocal:
type: boolean
description: Whether the user belongs to the local installation or not.
username:
type: string
description: A friendly name for a given user account
@@ -209,6 +212,9 @@ UserObjectFull:
type: number
description: A user identifier
example: 1
isLocal:
type: boolean
description: Whether the user belongs to the local installation or not.
username:
type: string
description: A friendly name for a given user account
@@ -449,6 +455,9 @@ UserObjectFull:
type: boolean
canFlag:
type: boolean
flagId:
type: number
nullable: true
canChangePassword:
type: boolean
isSelf:
@@ -537,6 +546,9 @@ UserObjectSlim:
type: number
description: A user identifier
example: 1
isLocal:
type: boolean
description: Whether the user belongs to the local installation or not.
username:
type: string
description: A friendly name for a given user account
@@ -625,6 +637,9 @@ UserObjectACP:
type: number
description: A user identifier
example: 1
isLocal:
type: boolean
description: Whether the user belongs to the local installation or not.
username:
type: string
description: A friendly name for a given user account

View File

@@ -92,6 +92,8 @@ paths:
$ref: 'read/admin/settings/user.yaml'
/api/admin/settings/post:
$ref: 'read/admin/settings/post.yaml'
/api/admin/settings/activitypub:
$ref: 'read/admin/settings/activitypub.yaml'
/api/admin/settings/advanced:
$ref: 'read/admin/settings/advanced.yaml'
/api/admin/manage/categories:
@@ -100,6 +102,8 @@ paths:
$ref: 'read/admin/manage/categories/category_id.yaml'
"/api/admin/manage/categories/{category_id}/analytics":
$ref: 'read/admin/manage/categories/category_id/analytics.yaml'
"/api/admin/manage/categories/{category_id}/federation":
$ref: 'read/admin/manage/categories/category_id/federation.yaml'
"/api/admin/manage/privileges/{cid}":
$ref: 'read/admin/manage/privileges/cid.yaml'
/api/admin/manage/tags:
@@ -326,5 +330,7 @@ paths:
$ref: 'read/groups/slug.yaml'
"/api/groups/{slug}/members":
$ref: 'read/groups/slug/members.yaml'
"/api/world":
$ref: 'read/world.yaml'
/api/outgoing:
$ref: 'read/outgoing.yaml'

View File

@@ -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

View File

@@ -45,6 +45,9 @@ get:
type: string
setting:
type: boolean
isLocal:
type: boolean
description: Whether the user belongs to the local installation or not.
default:
type: string
required:

View File

@@ -0,0 +1,19 @@
get:
tags:
- admin
summary: Get federation (ActivityPub) settings
responses:
"200":
description: ""
content:
application/json:
schema:
allOf:
- type: object
properties:
title:
type: string
instanceCount:
type: number
description: The number of ActivityPub-enabled instances that this forum knows about.
- $ref: ../../../components/schemas/CommonProps.yaml#/CommonProps

View File

@@ -151,6 +151,9 @@ get:
uid:
type: number
description: A user identifier
isLocal:
type: boolean
description: Whether the user belongs to the local installation or not.
username:
type: string
description: A friendly name for a given user account
@@ -220,6 +223,9 @@ get:
uid:
type: number
example: 1
isLocal:
type: boolean
description: Whether the user belongs to the local installation or not.
username:
type: string
example: Dragon Fruit

View File

@@ -178,6 +178,9 @@ get:
`icon:text` for the user's
auto-generated icon
example: "#f44336"
isLocal:
type: boolean
description: Whether the user belongs to the local installation or not.
index:
type: number
cid:
@@ -242,6 +245,9 @@ get:
'icon:bgColor':
type: string
example: '#9c27b0'
isLocal:
type: boolean
description: Whether the user belongs to the local installation or not.
imageClass:
type: string
- $ref: ../components/schemas/Pagination.yaml#/Pagination

View File

@@ -49,6 +49,9 @@ get:
uid:
type: number
description: A user identifier
isLocal:
type: boolean
description: Whether the user belongs to the local installation or not.
icon:text:
type: string
description: A single-letter representation of a username. This is used in the

View File

@@ -90,6 +90,9 @@ get:
uid:
type: number
description: A user identifier
isLocal:
type: boolean
description: Whether the user belongs to the local installation or not.
username:
type: string
description: A friendly name for a given user account
@@ -184,6 +187,8 @@ get:
type: boolean
downvoted:
type: boolean
attachments:
type: array
replies:
type: object
properties:
@@ -428,6 +433,9 @@ get:
type: string
displayname:
type: string
isLocal:
type: boolean
description: Whether the user belongs to the local installation or not.
- type: object
description: Optional properties that may or may not be present (except for `tid`, which is always present, and is only here as a hack to pass validation)
properties:

View File

@@ -66,6 +66,9 @@ get:
uid:
type: number
description: A user identifier
isLocal:
type: boolean
description: Whether the user belongs to the local installation or not.
username:
type: string
description: A friendly name for a given user account

View File

@@ -90,6 +90,9 @@ get:
uid:
type: number
description: A user identifier
isLocal:
type: boolean
description: Whether the user belongs to the local installation or not.
username:
type: string
description: A friendly name for a given user account
@@ -141,6 +144,9 @@ get:
uid:
type: number
description: A user identifier
isLocal:
type: boolean
description: Whether the user belongs to the local installation or not.
username:
type: string
description: A friendly name for a given user account
@@ -213,6 +219,9 @@ get:
uid:
type: number
description: A user identifier
isLocal:
type: boolean
description: Whether the user belongs to the local installation or not.
username:
type: string
description: A friendly name for a given user account
@@ -268,6 +277,9 @@ get:
uid:
type: number
description: A user identifier
isLocal:
type: boolean
description: Whether the user belongs to the local installation or not.
username:
type: string
description: A friendly name for a given user account
@@ -306,6 +318,9 @@ get:
uid:
type: number
description: A user identifier
isLocal:
type: boolean
description: Whether the user belongs to the local installation or not.
username:
type: string
description: A friendly name for a given user account
@@ -366,6 +381,9 @@ get:
uid:
type: number
description: A user identifier
isLocal:
type: boolean
description: Whether the user belongs to the local installation or not.
username:
type: string
description: A friendly name for a given user account
@@ -419,6 +437,9 @@ get:
uid:
type: number
description: A user identifier
isLocal:
type: boolean
description: Whether the user belongs to the local installation or not.
username:
type: string
description: A friendly name for a given user account
@@ -457,6 +478,9 @@ get:
uid:
type: number
description: A user identifier
isLocal:
type: boolean
description: Whether the user belongs to the local installation or not.
username:
type: string
description: A friendly name for a given user account

View File

@@ -35,6 +35,9 @@ get:
uid:
type: number
description: A user identifier
isLocal:
type: boolean
description: Whether the user belongs to the local installation or not.
username:
type: string
description: A friendly name for a given user account

View 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

View File

@@ -136,6 +136,8 @@ paths:
$ref: 'write/categories/cid/privileges/privilege.yaml'
/categories/{cid}/moderator/{uid}:
$ref: 'write/categories/cid/moderator/uid.yaml'
/categories/{cid}/follow:
$ref: 'write/categories/cid/follow.yaml'
/topics/:
$ref: 'write/topics.yaml'
/topics/{tid}:
@@ -186,6 +188,10 @@ paths:
$ref: 'write/posts/pid/voters.yaml'
/posts/{pid}/upvoters:
$ref: 'write/posts/pid/upvoters.yaml'
/posts/{pid}/announcers:
$ref: 'write/posts/pid/announcers.yaml'
/posts/{pid}/announcers/tooltip:
$ref: 'write/posts/pid/announcers-tooltip.yaml'
/posts/{pid}/bookmark:
$ref: 'write/posts/pid/bookmark.yaml'
/posts/{pid}/diffs:

View 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: {}

View File

@@ -64,6 +64,9 @@ put:
type: number
description: A user identifier
example: 1
isLocal:
type: boolean
description: Whether the user belongs to the local installation or not.
username:
type: string
description: A friendly name for a given user account

View File

@@ -64,6 +64,11 @@ get:
type: boolean
downvoted:
type: boolean
attachments:
type: array
items:
type: string
description: A sha256 hash of the attachment (tied to the corresponding entry in the database)
put:
tags:
- posts

View 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

View 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

View File

@@ -61,7 +61,12 @@ post:
status:
$ref: ../../components/schemas/Status.yaml#/Status
response:
$ref: ../../components/schemas/PostObject.yaml#/PostObject
allOf:
- $ref: ../../components/schemas/PostObject.yaml#/PostObject
- type: object
properties:
index:
type: number
delete:
tags:
- topics

View File

@@ -33,14 +33,6 @@
#available {
.drag-item {
cursor: move;
margin-right: 10px;
padding: 8px 10px;
margin-bottom: 5px;
}
p {
line-height: 20px;
min-height: 40px;
}
}

View 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);
}
}
});
}

View File

@@ -200,7 +200,7 @@ define('admin/manage/privileges', [
ajaxify.data.privileges = { ...ajaxify.data.privileges, ...privileges };
const tpl = parseInt(cid, 10) ? 'admin/partials/privileges/category' : 'admin/partials/privileges/global';
const isAdminPriv = ajaxify.currentPage.endsWith('admin/manage/privileges/admin');
app.parseAndTranslate(tpl, { privileges, isAdminPriv }).then((html) => {
app.parseAndTranslate(tpl, { cid, privileges, isAdminPriv }).then((html) => {
// Get currently selected filters
const btnIndices = $('.privilege-filters button.btn-warning').map((idx, el) => $(el).index()).get();
$('.privilege-table-container').html(html);
@@ -228,7 +228,7 @@ define('admin/manage/privileges', [
applyPrivileges(bannedUsersPrivs, getBannedUsersInputSelector);
// For rest that inherits from registered-users
const getRegisteredUsersInputSelector = (privs, i) => `.privilege-table tr[data-group-name]:not([data-group-name="registered-users"],[data-group-name="banned-users"],[data-group-name="guests"],[data-group-name="spiders"]) td[data-privilege="${privs[i]}"] input, .privilege-table tr[data-uid]:not([data-banned]) td[data-privilege="${privs[i]}"] input`;
const getRegisteredUsersInputSelector = (privs, i) => `.privilege-table tr[data-group-name]:not([data-group-name="registered-users"],[data-group-name="banned-users"],[data-group-name="guests"],[data-group-name="spiders"],[data-group-name="fediverse"]) td[data-privilege="${privs[i]}"] input, .privilege-table tr[data-uid]:not([data-banned]) td[data-privilege="${privs[i]}"] input`;
const registeredUsersPrivs = getPrivilegesFromRow('registered-users');
applyPrivileges(registeredUsersPrivs, getRegisteredUsersInputSelector);
};
@@ -240,7 +240,7 @@ define('admin/manage/privileges', [
inputSelectorFn = () => `.privilege-table tr[data-banned] td[data-privilege]:nth-child(${columnNo}) input`;
break;
default:
inputSelectorFn = () => `.privilege-table tr[data-group-name]:not([data-group-name="registered-users"],[data-group-name="banned-users"],[data-group-name="guests"],[data-group-name="spiders"]) td[data-privilege]:nth-child(${columnNo}) input, .privilege-table tr[data-uid]:not([data-banned]) td[data-privilege]:nth-child(${columnNo}) input`;
inputSelectorFn = () => `.privilege-table tr[data-group-name]:not([data-group-name="registered-users"],[data-group-name="banned-users"],[data-group-name="guests"],[data-group-name="spiders"],[data-group-name="fediverse"]) td[data-privilege]:nth-child(${columnNo}) input, .privilege-table tr[data-uid]:not([data-banned]) td[data-privilege]:nth-child(${columnNo}) input`;
}
const sourceChecked = getPrivilegeFromColumn(sourceGroupName, columnNo);

View File

@@ -510,7 +510,7 @@ define('admin/manage/users', [
if (confirm) {
Promise.all(
uids.map(
uid => api.del(`/users/${uid}${path}`, {}).then(() => {
uid => api.del(`/users/${encodeURIComponent(uid)}${path}`, {}).then(() => {
if (path !== '/content') {
removeRow(uid);
}

View File

@@ -23,6 +23,66 @@ ajaxify.widgets = { render: render };
if ('scrollRestoration' in history) {
history.scrollRestoration = 'manual';
}
ajaxify.check = (item) => {
/**
* returns:
* true (ajaxify OK)
* false (browser default)
* null (no action)
*/
let urlObj;
let pathname = item instanceof Element ? item.getAttribute('href') : undefined;
try {
urlObj = new URL(item, `${document.location.origin}${config.relative_path}`);
if (!pathname) {
({ pathname } = urlObj);
}
} catch (e) {
return false;
}
const internalLink = utils.isInternalURI(urlObj, window.location, config.relative_path);
// eslint-disable-next-line no-script-url
const hrefEmpty = href => href === undefined || href === '' || href === 'javascript:;';
if (item instanceof Element) {
if (item.getAttribute('data-ajaxify') === 'false') {
if (!internalLink) {
return false;
}
return null;
}
// eslint-disable-next-line no-script-url
if (hrefEmpty(urlObj.href) || urlObj.protocol === 'javascript:' || pathname === '#' || pathname === '') {
return null;
}
}
if (internalLink) {
// Default behaviour for rss feeds
if (pathname.endsWith('.rss')) {
return false;
}
// Default behaviour for sitemap
if (String(pathname).startsWith(config.relative_path + '/sitemap') && pathname.endsWith('.xml')) {
return false;
}
// Default behaviour for uploads and direct links to API urls
if (['/uploads', '/assets/', '/api/'].some(function (prefix) {
return String(pathname).startsWith(config.relative_path + prefix);
})) {
return false;
}
}
return true;
};
ajaxify.go = function (url, callback, quiet) {
// Automatically reconnect to socket and re-ajaxify on success
if (!socket.connected && parseInt(app.user.uid, 10) >= 0) {
@@ -512,10 +572,6 @@ $(document).ready(function () {
});
function ajaxifyAnchors() {
function hrefEmpty(href) {
// eslint-disable-next-line no-script-url
return href === undefined || href === '' || href === 'javascript:;';
}
const location = document.location || window.location;
const rootUrl = location.protocol + '//' + (location.hostname || location.host) + (location.port ? ':' + location.port : '');
const contentEl = document.getElementById('content');
@@ -527,10 +583,7 @@ $(document).ready(function () {
return;
}
const $this = $(this);
const href = $this.attr('href');
const internalLink = utils.isInternalURI(this, window.location, config.relative_path);
const rootAndPath = new RegExp(`^${rootUrl}${config.relative_path}/?`);
const process = function () {
if (!e.ctrlKey && !e.shiftKey && !e.metaKey && e.which === 1) {
@@ -561,52 +614,36 @@ $(document).ready(function () {
}
};
if ($this.attr('data-ajaxify') === 'false') {
if (!internalLink) {
return;
}
return e.preventDefault();
}
// Default behaviour for rss feeds
if (internalLink && href && href.endsWith('.rss')) {
return;
}
// Default behaviour for sitemap
if (internalLink && href && String(_self.pathname).startsWith(config.relative_path + '/sitemap') && href.endsWith('.xml')) {
return;
}
// Default behaviour for uploads and direct links to API urls
if (internalLink && ['/uploads', '/assets/', '/api/'].some(function (prefix) {
return String(_self.pathname).startsWith(config.relative_path + prefix);
})) {
return;
}
// eslint-disable-next-line no-script-url
if (hrefEmpty(this.href) || this.protocol === 'javascript:' || href === '#' || href === '') {
return e.preventDefault();
}
if (app.flags && app.flags.hasOwnProperty('_unsaved') && app.flags._unsaved === true) {
if (e.ctrlKey) {
return;
}
require(['bootbox'], function (bootbox) {
bootbox.confirm('[[global:unsaved-changes]]', function (navigate) {
if (navigate) {
app.flags._unsaved = false;
process.call(_self);
const check = ajaxify.check(this);
switch (check) {
case true: {
if (app.flags && app.flags.hasOwnProperty('_unsaved') && app.flags._unsaved === true) {
if (e.ctrlKey) {
return;
}
});
});
return e.preventDefault();
}
process.call(_self);
require(['bootbox'], function (bootbox) {
bootbox.confirm('[[global:unsaved-changes]]', function (navigate) {
if (navigate) {
app.flags._unsaved = false;
process.call(_self);
}
});
});
return e.preventDefault();
}
process.call(_self);
break;
}
case null: {
e.preventDefault();
break;
}
// default is default browser behaviour
}
});
}

View File

@@ -358,6 +358,20 @@ if (document.readyState === 'loading') {
if (!config.useragent.isSafari && 'serviceWorker' in navigator) {
navigator.serviceWorker.register(config.relative_path + '/service-worker.js', { scope: config.relative_path + '/' })
.then(function () {
navigator.serviceWorker.addEventListener('message', (event) => {
const { action, url } = event.data;
switch (action) {
case 'ajaxify': {
const check = ajaxify.check(url);
if (check) {
ajaxify.go(url);
} else {
window.location.href = url;
}
}
}
});
console.info('ServiceWorker registration succeeded.');
}).catch(function (err) {
console.info('ServiceWorker registration failed: ', err);

View File

@@ -56,6 +56,7 @@ define('forum/account/header', [
components.get('account/delete-content').on('click', () => AccountsDelete.content(ajaxify.data.theirid));
components.get('account/delete-all').on('click', () => AccountsDelete.purge(ajaxify.data.theirid));
components.get('account/flag').on('click', flagAccount);
components.get('account/already-flagged').on('click', rescindAccountFlag);
components.get('account/block').on('click', () => toggleBlockAccount('block'));
components.get('account/unblock').on('click', () => toggleBlockAccount('unblock'));
};
@@ -108,7 +109,8 @@ define('forum/account/header', [
}
function toggleFollow(type) {
api[type === 'follow' ? 'put' : 'del']('/users/' + ajaxify.data.uid + '/follow', undefined, function (err) {
const target = isFinite(ajaxify.data.uid) ? ajaxify.data.uid : encodeURIComponent(ajaxify.data.userslug);
api[type === 'follow' ? 'put' : 'del']('/users/' + target + '/follow', undefined, function (err) {
if (err) {
return alerts.error(err);
}
@@ -129,6 +131,18 @@ define('forum/account/header', [
});
}
function rescindAccountFlag() {
const flagId = $(this).data('flag-id');
require(['flags'], function (flags) {
bootbox.confirm('[[flags:modal-confirm-rescind]]', function (confirm) {
if (!confirm) {
return;
}
flags.rescind(flagId);
});
});
}
function toggleBlockAccount(action) {
socket.emit('user.toggleBlock', {
blockeeUid: ajaxify.data.uid,

View File

@@ -213,13 +213,34 @@ export function handleBulkActions() {
const subselector = e.target.closest('[data-action]');
if (subselector) {
const action = subselector.getAttribute('data-action');
let confirmed;
if (action === 'bulk-purge') {
confirmed = new Promise((resolve, reject) => {
bootbox.confirm('[[flags:confirm-purge]]', (confirmed) => {
if (confirmed) {
resolve();
} else {
reject(new Error('[[flags:purge-cancelled]]'));
}
});
});
}
const flagIds = getSelected();
const promises = flagIds.map((flagId) => {
const promises = flagIds.map(async (flagId) => {
const data = {};
if (action === 'bulk-assign') {
data.assignee = app.user.uid;
} else if (action === 'bulk-mark-resolved') {
data.state = 'resolved';
switch (action) {
case 'bulk-assign': {
data.assignee = app.user.uid;
break;
}
case 'bulk-mark-resolved': {
data.state = 'resolved';
break;
}
case 'bulk-purge': {
await confirmed;
return api.del(`/flags/${flagId}`);
}
}
return api.put(`/flags/${flagId}`, data);
});

View File

@@ -302,7 +302,7 @@ define('forum/topic', [
destroyed = false;
async function renderPost(pid) {
const postData = postCache[pid] || await api.get(`/posts/${pid}/summary`);
const postData = postCache[pid] || await api.get(`/posts/${encodeURIComponent(pid)}/summary`);
$('#post-tooltip').remove();
if (postData && ajaxify.data.template.topic) {
postCache[pid] = postData;
@@ -329,11 +329,11 @@ define('forum/topic', [
const pathname = location.pathname;
const validHref = href && href !== '#' && window.location.hostname === location.hostname;
$('#post-tooltip').remove();
const postMatch = validHref && pathname && pathname.match(/\/post\/([\d]+)/);
const topicMatch = validHref && pathname && pathname.match(/\/topic\/([\d]+)/);
const postMatch = validHref && pathname && pathname.match(/\/post\/([\d]+|(?:[\w_.~!$&'()*+,;=:@-]|%[\dA-F]{2})+)/);
const topicMatch = validHref && pathname && pathname.match(/\/topic\/([\da-z-]+)/);
if (postMatch) {
const pid = postMatch[1];
if (parseInt(link.parents('[component="post"]').attr('data-pid'), 10) === parseInt(pid, 10)) {
if (encodeURIComponent(link.parents('[component="post"]').attr('data-pid')) === encodeURIComponent(pid)) {
return; // dont render self post
}

View File

@@ -35,10 +35,10 @@ define('forum/topic/delete-posts', [
showPostsSelected();
deleteBtn.on('click', function () {
deletePosts(deleteBtn, pid => `/posts/${pid}/state`);
deletePosts(deleteBtn, pid => `/posts/${encodeURIComponent(pid)}/state`);
});
purgeBtn.on('click', function () {
deletePosts(purgeBtn, pid => `/posts/${pid}`);
deletePosts(purgeBtn, pid => `/posts/${encodeURIComponent(pid)}`);
});
});
};

View File

@@ -9,7 +9,7 @@ define('forum/topic/diffs', ['api', 'bootbox', 'alerts', 'forum/topic/images'],
return;
}
api.get(`/posts/${pid}/diffs`, {}).then((data) => {
api.get(`/posts/${encodeURIComponent(pid)}/diffs`, {}).then((data) => {
parsePostHistory(data).then(($html) => {
const $modal = bootbox.dialog({
title: '[[topic:diffs.title]]',
@@ -57,7 +57,7 @@ define('forum/topic/diffs', ['api', 'bootbox', 'alerts', 'forum/topic/images'],
return;
}
api.get(`/posts/${pid}/diffs/${since}`, {}).then((data) => {
api.get(`/posts/${encodeURIComponent(pid)}/diffs/${since}`, {}).then((data) => {
data.deleted = !!parseInt(data.deleted, 10);
app.parseAndTranslate('partials/posts_list', 'posts', {
@@ -74,14 +74,14 @@ define('forum/topic/diffs', ['api', 'bootbox', 'alerts', 'forum/topic/images'],
return;
}
api.put(`/posts/${pid}/diffs/${since}`, {}).then(() => {
api.put(`/posts/${encodeURIComponent(pid)}/diffs/${since}`, {}).then(() => {
$modal.modal('hide');
alerts.success('[[topic:diffs.post-restored]]');
}).catch(alerts.error);
};
Diffs.delete = function (pid, timestamp, $selectEl, $numberOfDiffCon) {
api.del(`/posts/${pid}/diffs/${timestamp}`).then((data) => {
api.del(`/posts/${encodeURIComponent(pid)}/diffs/${timestamp}`).then((data) => {
parsePostHistory(data, 'diffs').then(($html) => {
$selectEl.empty().append($html);
$selectEl.trigger('change');

View File

@@ -71,7 +71,7 @@ define('forum/topic/events', [
function updatePostVotesAndUserReputation(data) {
const votes = $('[data-pid="' + data.post.pid + '"] [component="post/vote-count"]').filter(function (index, el) {
return parseInt($(el).closest('[data-pid]').attr('data-pid'), 10) === parseInt(data.post.pid, 10);
return $(el).closest('[data-pid]').attr('data-pid') === String(data.post.pid);
});
const reputationElements = $('.reputation[data-uid="' + data.post.uid + '"]');
votes.html(data.post.votes).attr('data-votes', data.post.votes);
@@ -101,15 +101,15 @@ define('forum/topic/events', [
}
function onPostEdited(data) {
if (!data || !data.post || parseInt(data.post.tid, 10) !== parseInt(ajaxify.data.tid, 10)) {
if (!data || !data.post || String(data.post.tid) !== String(ajaxify.data.tid)) {
return;
}
const editedPostEl = components.get('post/content', data.post.pid).filter(function (index, el) {
return parseInt($(el).closest('[data-pid]').attr('data-pid'), 10) === parseInt(data.post.pid, 10);
return String($(el).closest('[data-pid]').attr('data-pid')) === String(data.post.pid);
});
const postContainer = $(`[data-pid="${data.post.pid}"]`);
const editorEl = postContainer.find('[component="post/editor"]').filter(function (index, el) {
return parseInt($(el).closest('[data-pid]').attr('data-pid'), 10) === parseInt(data.post.pid, 10);
return String($(el).closest('[data-pid]').attr('data-pid')) === String(data.post.pid);
});
const topicTitle = components.get('topic/title');
const navbarTitle = components.get('navbar/title').find('span');
@@ -225,10 +225,10 @@ define('forum/topic/events', [
function togglePostVote(data) {
const post = $('[data-pid="' + data.post.pid + '"]');
post.find('[component="post/upvote"]').filter(function (index, el) {
return parseInt($(el).closest('[data-pid]').attr('data-pid'), 10) === parseInt(data.post.pid, 10);
return $(el).closest('[data-pid]').attr('data-pid') === String(data.post.pid);
}).toggleClass('upvoted', data.upvote);
post.find('[component="post/downvote"]').filter(function (index, el) {
return parseInt($(el).closest('[data-pid]').attr('data-pid'), 10) === parseInt(data.post.pid, 10);
return $(el).closest('[data-pid]').attr('data-pid') === String(data.post.pid);
}).toggleClass('downvoted', data.downvote);
}

View File

@@ -141,7 +141,7 @@ define('forum/topic/move-post', [
return;
}
Promise.all(data.pids.map(pid => api.put(`/posts/${pid}/move`, {
Promise.all(data.pids.map(pid => api.put(`/posts/${encodeURIComponent(pid)}/move`, {
tid: data.tid,
}))).then(() => {
data.pids.forEach(function (pid) {

View File

@@ -141,6 +141,10 @@ define('forum/topic/postTools', [
votes.showVotes(getData($(this), 'data-pid'));
});
postContainer.on('click', '[component="post/announce-count"]', function () {
votes.showAnnouncers(getData($(this), 'data-pid'));
});
postContainer.on('click', '[component="post/flag"]', function () {
const pid = getData($(this), 'data-pid');
require(['flags'], function (flags) {
@@ -151,6 +155,18 @@ define('forum/topic/postTools', [
});
});
postContainer.on('click', '[component="post/already-flagged"]', function () {
const flagId = $(this).data('flag-id');
require(['flags'], function (flags) {
bootbox.confirm('[[flags:modal-confirm-rescind]]', function (confirm) {
if (!confirm) {
return;
}
flags.rescind(flagId);
});
});
});
postContainer.on('click', '[component="post/flagUser"]', function () {
const uid = getData($(this), 'data-uid');
require(['flags'], function (flags) {
@@ -322,7 +338,7 @@ define('forum/topic/postTools', [
return quote(selectedNode.text);
}
const { content } = await api.get(`/posts/${toPid}/raw`);
const { content } = await api.get(`/posts/${encodeURIComponent(toPid)}/raw`);
quote(content);
});
}
@@ -352,7 +368,7 @@ define('forum/topic/postTools', [
function bookmarkPost(button, pid) {
const method = button.attr('data-bookmarked') === 'false' ? 'put' : 'del';
api[method](`/posts/${pid}/bookmark`, undefined, function (err) {
api[method](`/posts/${encodeURIComponent(pid)}/bookmark`, undefined, function (err) {
if (err) {
return alerts.error(err);
}
@@ -421,7 +437,7 @@ define('forum/topic/postTools', [
const route = action === 'purge' ? '' : '/state';
const method = action === 'restore' ? 'put' : 'del';
api[method](`/posts/${pid}${route}`).catch(alerts.error);
api[method](`/posts/${encodeURIComponent(pid)}${route}`).catch(alerts.error);
});
}

View File

@@ -256,7 +256,7 @@ define('forum/topic/posts', [
const after = parseInt(afterEl.attr('data-index'), 10) || 0;
const tid = ajaxify.data.tid;
if (!utils.isNumber(tid) || !utils.isNumber(after) || (direction < 0 && components.get('post', 'index', 0).length)) {
if (!utils.isNumber(after) || (direction < 0 && components.get('post', 'index', 0).length)) {
return;
}

View File

@@ -14,7 +14,7 @@ define('forum/topic/replies', ['forum/topic/posts', 'hooks', 'alerts', 'api'], f
.removeClass('fa-chevron-down')
.addClass('fa-spin fa-spinner');
api.get(`/posts/${pid}/replies`, {}, function (err, { replies }) {
api.get(`/posts/${encodeURIComponent(pid)}/replies`, {}, function (err, { replies }) {
const postData = replies;
open.removeAttr('loading')
.attr('loaded', '1')

View File

@@ -13,6 +13,9 @@ define('forum/topic/votes', [
components.get('topic').on('mouseenter', '[data-pid] [component="post/vote-count"]', loadDataAndCreateTooltip);
components.get('topic').on('mouseleave', '[data-pid] [component="post/vote-count"]', destroyTooltip);
}
components.get('topic').on('mouseenter', '[data-pid] [component="post/announce-count"]', loadDataAndCreateTooltip);
components.get('topic').on('mouseleave', '[data-pid] [component="post/announce-count"]', destroyTooltip);
};
function canSeeUpVotes() {
@@ -50,8 +53,11 @@ define('forum/topic/votes', [
tooltip.dispose();
$this.attr('title', '');
}
const path = $this.attr('component') === 'post/vote-count' ?
`/posts/${encodeURIComponent(pid)}/upvoters` :
`/posts/${encodeURIComponent(pid)}/announcers/tooltip`;
api.get(`/posts/${pid}/upvoters`, {}, function (err, data) {
api.get(path, {}, function (err, data) {
if (err) {
return alerts.error(err);
}
@@ -93,7 +99,7 @@ define('forum/topic/votes', [
const method = currentState ? 'del' : 'put';
const pid = post.attr('data-pid');
api[method](`/posts/${pid}/vote`, {
api[method](`/posts/${encodeURIComponent(pid)}/vote`, {
delta: delta,
}, function (err) {
if (err) {
@@ -117,7 +123,7 @@ define('forum/topic/votes', [
if (!canSeeVotes()) {
return;
}
api.get(`/posts/${pid}/voters`, {}, function (err, data) {
api.get(`/posts/${encodeURIComponent(pid)}/voters`, {}, function (err, data) {
if (err) {
return alerts.error(err);
}
@@ -139,6 +145,24 @@ define('forum/topic/votes', [
});
};
Votes.showAnnouncers = async function (pid) {
const data = await api.get(`/posts/${encodeURIComponent(pid)}/announcers`, {})
.catch(err => alerts.error(err));
const html = await app.parseAndTranslate('modals/announcers', data);
const dialog = bootbox.dialog({
title: `[[activitypub:announcers-x, ${data.announceCount}]]`,
message: html,
className: 'announce-modal',
show: true,
onEscape: true,
backdrop: true,
});
dialog.on('click', function () {
dialog.modal('hide');
});
};
return Votes;
});

View 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;
});

View File

@@ -62,6 +62,11 @@ async function xhr(options) {
const res = await fetch(url, options);
const { headers } = res;
if (headers.get('x-redirect')) {
return xhr({ url: headers.get('x-redirect'), ...options });
}
const contentType = headers.get('content-type');
const isJSON = contentType && contentType.startsWith('application/json');

View File

@@ -8,6 +8,8 @@ define('flags', ['hooks', 'components', 'api', 'alerts'], function (hooks, compo
let flagReason;
Flag.showFlagModal = function (data) {
data.remote = URL.canParse(data.id) ? new URL(data.id).hostname : false;
app.parseAndTranslate('modals/flag', data, function (html) {
flagModal = html;
flagModal.on('hidden.bs.modal', function () {
@@ -35,18 +37,21 @@ define('flags', ['hooks', 'components', 'api', 'alerts'], function (hooks, compo
if (selected.attr('id') === 'flag-reason-other') {
reason = flagReason.val();
}
createFlag(data.type, data.id, reason);
const notifyRemote = $('input[name="flag-notify-remote"]').is(':checked');
createFlag(data.type, data.id, reason, notifyRemote);
});
flagModal.on('click', '#flag-reason-other', function () {
flagReason.focus();
});
flagModal.modal('show');
hooks.fire('action:flag.showModal', {
modalEl: flagModal,
type: data.type,
id: data.id,
remote: data.remote,
});
flagModal.find('#flag-reason-custom').on('keyup blur change', checkFlagButtonEnable);
@@ -62,11 +67,26 @@ define('flags', ['hooks', 'components', 'api', 'alerts'], function (hooks, compo
}).catch(alerts.error);
};
function createFlag(type, id, reason) {
Flag.rescind = function (flagId) {
api.del(`/flags/${flagId}/report`).then(() => {
alerts.success('[[flags:report-rescinded]]');
hooks.fire('action:flag.rescinded', { flagId: flagId });
}).catch(alerts.error);
};
Flag.purge = function (flagId) {
api.del(`/flags/${flagId}`).then(() => {
alerts.success('[[flags:purged]]');
hooks.fire('action:flag.purged', { flagId: flagId });
}).catch(alerts.error);
};
function createFlag(type, id, reason, notifyRemote = false) {
if (!type || !id || !reason) {
return;
}
const data = { type: type, id: id, reason: reason };
const data = { type: type, id: id, reason: reason, notifyRemote: notifyRemote };
api.post('/flags', data, function (err, flagId) {
if (err) {
return alerts.error(err);

View File

@@ -28,6 +28,7 @@ module.exports = function (utils, Benchpress, relative_path) {
generateWroteReplied,
generateRepliedTo,
generateWrote,
encodeURIComponent: _encodeURIComponent,
isoTimeToLocaleString,
shouldHideReplyContainer,
humanReadableNumber,
@@ -176,7 +177,7 @@ module.exports = function (utils, Benchpress, relative_path) {
return '';
}
function spawnPrivilegeStates(member, privileges, types) {
function spawnPrivilegeStates(cid, member, privileges, types) {
const states = [];
for (const priv in privileges) {
if (privileges.hasOwnProperty(priv)) {
@@ -191,15 +192,20 @@ module.exports = function (utils, Benchpress, relative_path) {
const guestDisabled = ['groups:moderate', 'groups:posts:upvote', 'groups:posts:downvote', 'groups:local:login', 'groups:group:create'];
const spidersEnabled = ['groups:find', 'groups:read', 'groups:topics:read', 'groups:view:users', 'groups:view:tags', 'groups:view:groups'];
const globalModDisabled = ['groups:moderate'];
let fediverseEnabled = ['groups:view:users', 'groups:find', 'groups:read', 'groups:topics:read', 'groups:topics:create', 'groups:topics:reply', 'groups:topics:tag', 'groups:posts:edit', 'groups:posts:history', 'groups:posts:delete', 'groups:posts:upvote', 'groups:posts:downvote', 'groups:topics:delete'];
if (cid === -1) {
fediverseEnabled = fediverseEnabled.slice(3);
}
const disabled =
(member === 'guests' && (guestDisabled.includes(priv.name) || priv.name.startsWith('groups:admin:'))) ||
(member === 'spiders' && !spidersEnabled.includes(priv.name)) ||
(member === 'fediverse' && !fediverseEnabled.includes(priv.name)) ||
(member === 'Global Moderators' && globalModDisabled.includes(priv.name));
return `
<td data-privilege="${priv.name}" data-value="${priv.state}" data-type="${priv.type}">
<div class="form-check text-center">
<input class="form-check-input float-none" autocomplete="off" type="checkbox"${(priv.state ? ' checked' : '')}${(disabled ? ' disabled="disabled"' : '')} />
<input class="form-check-input float-none${(disabled ? ' d-none"' : '')}" autocomplete="off" type="checkbox"${(priv.state ? ' checked' : '')}${(disabled ? ' disabled="disabled" aria-diabled="true"' : '')} />
</div>
</td>
`;
@@ -334,13 +340,17 @@ module.exports = function (utils, Benchpress, relative_path) {
post.parent.displayname : '[[global:guest]]';
const isBeforeCutoff = post.timestamp < (Date.now() - (timeagoCutoff * oneDayInMs));
const langSuffix = isBeforeCutoff ? 'on' : 'ago';
return `[[topic:replied-to-user-${langSuffix}, ${post.toPid}, ${relative_path}/post/${post.toPid}, ${displayname}, ${relative_path}/post/${post.pid}, ${post.timestampISO}]]`;
return `[[topic:replied-to-user-${langSuffix}, ${post.toPid}, ${relative_path}/post/${encodeURIComponent(post.toPid)}, ${displayname}, ${relative_path}/post/${encodeURIComponent(post.pid)}, ${post.timestampISO}]]`;
}
function generateWrote(post, timeagoCutoff) {
const isBeforeCutoff = post.timestamp < (Date.now() - (timeagoCutoff * oneDayInMs));
const langSuffix = isBeforeCutoff ? 'on' : 'ago';
return `[[topic:wrote-${langSuffix}, ${relative_path}/post/${post.pid}, ${post.timestampISO}]]`;
return `[[topic:wrote-${langSuffix}, ${relative_path}/post/${encodeURIComponent(post.pid)}, ${post.timestampISO}]]`;
}
function _encodeURIComponent(value) {
return encodeURIComponent(value);
}
function isoTimeToLocaleString(isoTime, locale = 'en-GB') {

View File

@@ -10,8 +10,8 @@
window.slugify = factory(XRegExp);
}
}(function (XRegExp) {
const invalidUnicodeChars = XRegExp('[^\\p{L}\\s\\d\\-_]', 'g');
const invalidLatinChars = /[^\w\s\d\-_]/g;
const invalidUnicodeChars = XRegExp('[^\\p{L}\\s\\d\\-_@.]', 'g');
const invalidLatinChars = /[^\w\s\d\-_@.]/g;
const trimRegex = /^\s+|\s+$/g;
const collapseWhitespace = /\s+/g;
const collapseDash = /-+/g;

View File

@@ -144,7 +144,7 @@ define('tagFilter', ['hooks', 'alerts', 'bootstrap'], function (hooks, alerts, b
function loadList(query, callback) {
let cids = null;
if (ajaxify.data.template.category) {
if (ajaxify.data.template.category || ajaxify.data.template.world) {
cids = [ajaxify.data.cid];
// selectedCids is avaiable on /recent, /unread, /popular etc.
} else if (Array.isArray(ajaxify.data.selectedCids) && ajaxify.data.selectedCids.length) {

View File

@@ -7,7 +7,7 @@ define('topicThumbs', [
Thumbs.get = id => api.get(`/topics/${id}/thumbs`, {});
Thumbs.getByPid = pid => api.get(`/posts/${pid}`, {}).then(post => Thumbs.get(post.tid));
Thumbs.getByPid = pid => api.get(`/posts/${encodeURIComponent(pid)}`, {}).then(post => Thumbs.get(post.tid));
Thumbs.delete = (id, path) => api.del(`/topics/${id}/thumbs`, {
path: path,

View File

@@ -1,5 +1,15 @@
'use strict';
self.addEventListener('install', () => {
// Register self as the primary service worker
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
// Take responsibility over existing clients from old service worker
event.waitUntil(self.clients.claim());
});
self.addEventListener('fetch', function (event) {
// This is the code that ignores post requests
// https://github.com/NodeBB/NodeBB/issues/9151
@@ -17,3 +27,65 @@ self.addEventListener('fetch', function (event) {
return response;
}));
});
/**
* The following code is used by nodebb-plugin-web-push
* There is a very strong argument to be made that this is plugin-specific
* code and does not belong in core.
*
* Additional R&D is required to determine how to allow plugins to inject
* code into the service worker.
*/
// Register event listener for the 'push' event.
self.addEventListener('push', function (event) {
// Keep the service worker alive until the notification is created.
const { title, body, tag, data } = event.data.json();
if (title && body) {
const icon = data.icon;
delete data.icon;
const badge = data.badge;
delete data.badge;
event.waitUntil(
self.registration.showNotification(title, { body, tag, data, icon, badge })
);
} else if (tag) {
event.waitUntil(
self.registration.getNotifications({ tag }).then((notifications) => {
notifications.forEach((notification) => {
notification.close();
});
})
);
}
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
let target;
if (event.notification.data && event.notification.data.url) {
target = new URL(event.notification.data.url);
}
// This looks to see if the current is already open and focuses if it is
event.waitUntil(
self.clients
.matchAll({ type: 'window' })
.then((clientList) => {
// eslint-disable-next-line no-restricted-syntax
for (const client of clientList) {
const { hostname } = new URL(client.url);
if (target && hostname === target.hostname && 'focus' in client) {
client.postMessage({
action: 'ajaxify',
url: target.pathname,
});
return client.focus();
}
}
if (self.clients.openWindow) return self.clients.openWindow(target.pathname);
})
);
});

323
src/activitypub/actors.js Normal file
View File

@@ -0,0 +1,323 @@
'use strict';
const nconf = require('nconf');
const winston = require('winston');
const db = require('../database');
const meta = require('../meta');
const batch = require('../batch');
const user = require('../user');
const utils = require('../utils');
const TTLCache = require('../cache/ttl');
const failedWebfingerCache = TTLCache({
max: 5000,
ttl: 1000 * 60 * 10, // 10 minutes
});
const activitypub = module.parent.exports;
const Actors = module.exports;
Actors.assert = async (ids, options = {}) => {
/**
* Ensures that the passed in ids or webfinger handles are stored in database.
* Options:
* - update: boolean, forces re-fetch/process of the resolved id
* Return one of:
* - An array of newly processed ids
* - false: if input incorrect (or webfinger handle cannot resolve)
* - true: no new IDs processed; all passed-in IDs present.
*/
// Handle single values
if (!Array.isArray(ids)) {
ids = [ids];
}
if (!ids.length) {
return false;
}
// Existance in failure cache is automatic assertion failure
if (ids.some(id => failedWebfingerCache.has(id))) {
return false;
}
// Filter out uids if passed in
ids = ids.filter(id => !utils.isNumber(id));
// Translate webfinger handles to uris
const hostMap = new Map();
ids = (await Promise.all(ids.map(async (id) => {
const originalId = id;
if (activitypub.helpers.isWebfinger(id)) {
const host = id.replace(/^(acct:|@)/, '').split('@')[1];
if (host === nconf.get('url_parsed').host) { // do not assert loopback ids
return 'loopback';
}
({ actorUri: id } = await activitypub.helpers.query(id));
hostMap.set(id, host);
}
// ensure the final id is a valid URI
if (!id || !activitypub.helpers.isUri(id)) {
failedWebfingerCache.set(originalId, true);
return;
}
return id;
})));
// Webfinger failures = assertion failure
if (!ids.every(Boolean)) {
return false;
}
// Filter out loopback uris
ids = ids.filter(uri => uri !== 'loopback' && new URL(uri).host !== nconf.get('url_parsed').host);
// Only assert those who haven't been seen recently (configurable), unless update flag passed in (force refresh)
if (!options.update) {
const upperBound = Date.now() - (1000 * 60 * 60 * 24 * meta.config.activitypubUserPruneDays);
const lastCrawled = await db.sortedSetScores('usersRemote:lastCrawled', ids.map(id => ((typeof id === 'object' && id.hasOwnProperty('id')) ? id.id : id)));
ids = ids.filter((id, idx) => {
const timestamp = lastCrawled[idx];
return !timestamp || timestamp < upperBound;
});
}
if (!ids.length) {
return true;
}
// winston.verbose(`[activitypub/actors] Asserting ${ids.length} actor(s)`);
// NOTE: MAKE SURE EVERY DB ADDITION HAS A CORRESPONDING REMOVAL IN ACTORS.REMOVE!
const urlMap = new Map();
const followersUrlMap = new Map();
const pubKeysMap = new Map();
let actors = await Promise.all(ids.map(async (id) => {
try {
// winston.verbose(`[activitypub/actors] Processing ${id}`);
const actor = (typeof id === 'object' && id.hasOwnProperty('id')) ? id : await activitypub.get('uid', 0, id, { cache: process.env.CI === 'true' });
if (
!activitypub._constants.acceptableActorTypes.has(actor.type) ||
!activitypub._constants.requiredActorProps.every(prop => actor.hasOwnProperty(prop))
) {
return null;
}
// Follow counts
try {
const [followers, following] = await Promise.all([
actor.followers ? activitypub.get('uid', 0, actor.followers) : { totalItems: 0 },
actor.following ? activitypub.get('uid', 0, actor.following) : { totalItems: 0 },
]);
actor.followerCount = followers.totalItems;
actor.followingCount = following.totalItems;
} catch (e) {
// no action required
// winston.verbose(`[activitypub/actor.assert] Unable to retrieve follower counts for ${actor.id}`);
}
// Save url for backreference
const url = Array.isArray(actor.url) ? actor.url.shift() : actor.url;
if (url && url !== actor.id) {
urlMap.set(url, actor.id);
}
// Save followers url for backreference
if (actor.hasOwnProperty('followers') && activitypub.helpers.isUri(actor.followers)) {
followersUrlMap.set(actor.followers, actor.id);
}
// Public keys
pubKeysMap.set(actor.id, actor.publicKey);
return actor;
} catch (e) {
if (e.code === 'ap_get_410') {
const exists = await user.exists(id);
if (exists) {
await user.deleteAccount(id);
}
}
return null;
}
}));
actors = actors.filter(Boolean); // remove unresolvable actors
// Build userData object for storage
const profiles = (await activitypub.mocks.profile(actors, hostMap)).filter(Boolean);
const now = Date.now();
const bulkSet = profiles.reduce((memo, profile) => {
const key = `userRemote:${profile.uid}`;
memo.push([key, profile], [`${key}:keys`, pubKeysMap.get(profile.uid)]);
return memo;
}, []);
if (urlMap.size) {
bulkSet.push(['remoteUrl:uid', Object.fromEntries(urlMap)]);
}
if (followersUrlMap.size) {
bulkSet.push(['followersUrl:uid', Object.fromEntries(followersUrlMap)]);
}
const exists = await db.isSortedSetMembers('usersRemote:lastCrawled', profiles.map(p => p.uid));
const uidsForCurrent = profiles.map((p, idx) => (exists[idx] ? p.uid : 0));
const current = await user.getUsersFields(uidsForCurrent, ['username', 'fullname']);
const queries = profiles.reduce((memo, profile, idx) => {
const { username, fullname } = current[idx];
if (username !== profile.username) {
if (uidsForCurrent[idx] !== 0) {
memo.searchRemove.push(['ap.preferredUsername:sorted', `${username.toLowerCase()}:${profile.uid}`]);
memo.handleRemove.push(username.toLowerCase());
}
memo.searchAdd.push(['ap.preferredUsername:sorted', 0, `${profile.username.toLowerCase()}:${profile.uid}`]);
memo.handleAdd[profile.username.toLowerCase()] = profile.uid;
}
if (profile.fullname && fullname !== profile.fullname) {
if (fullname && uidsForCurrent[idx] !== 0) {
memo.searchRemove.push(['ap.name:sorted', `${fullname.toLowerCase()}:${profile.uid}`]);
}
memo.searchAdd.push(['ap.name:sorted', 0, `${profile.fullname.toLowerCase()}:${profile.uid}`]);
}
return memo;
}, { searchRemove: [], searchAdd: [], handleRemove: [], handleAdd: {} });
await Promise.all([
db.setObjectBulk(bulkSet),
db.sortedSetAdd('usersRemote:lastCrawled', profiles.map(() => now), profiles.map(p => p.uid)),
db.sortedSetRemoveBulk(queries.searchRemove),
db.sortedSetAddBulk(queries.searchAdd),
db.deleteObjectFields('handle:uid', queries.handleRemove),
db.setObject('handle:uid', queries.handleAdd),
]);
return actors;
};
Actors.getLocalFollowers = async (id) => {
const response = {
uids: new Set(),
cids: new Set(),
};
if (!activitypub.helpers.isUri(id)) {
return response;
}
const members = await db.getSortedSetMembers(`followersRemote:${id}`);
members.forEach((id) => {
if (utils.isNumber(id)) {
response.uids.add(parseInt(id, 10));
} else if (id.startsWith('cid|') && utils.isNumber(id.slice(4))) {
response.cids.add(parseInt(id.slice(4), 10));
}
});
return response;
};
Actors.getLocalFollowCounts = async (actor) => {
let followers = 0; // x local followers
let following = 0; // following x local users
if (!activitypub.helpers.isUri(actor)) {
return { followers, following };
}
[followers, following] = await Promise.all([
db.sortedSetCard(`followersRemote:${actor}`),
db.sortedSetCard(`followingRemote:${actor}`),
]);
return { followers, following };
};
Actors.remove = async (id) => {
/**
* Remove ActivityPub related metadata pertaining to a remote id
*
* Note: don't call this directly! It is called as part of user.deleteAccount
*/
const exists = await db.isSortedSetMember('usersRemote:lastCrawled', id);
if (!exists) {
return false;
}
let { username, fullname, url, followersUrl } = await user.getUserFields(id, ['username', 'fullname', 'url', 'followersUrl']);
username = username.toLowerCase();
const bulkRemove = [
['ap.preferredUsername:sorted', `${username}:${id}`],
];
if (fullname) {
bulkRemove.push(['ap.name:sorted', `${fullname.toLowerCase()}:${id}`]);
}
await Promise.all([
db.sortedSetRemoveBulk(bulkRemove),
db.deleteObjectField('handle:uid', username),
db.deleteObjectField('followersUrl:uid', followersUrl),
db.deleteObjectField('remoteUrl:uid', url),
db.delete(`userRemote:${id}:keys`),
]);
await Promise.all([
db.delete(`userRemote:${id}`),
db.sortedSetRemove('usersRemote:lastCrawled', id),
]);
};
Actors.prune = async () => {
/**
* Clear out remote user accounts that do not have content on the forum anywhere
* Re-crawl those that have not been updated recently
*/
winston.info('[actors/prune] Started scheduled pruning of remote user accounts');
const days = parseInt(meta.config.activitypubUserPruneDays, 10);
const timestamp = Date.now() - (1000 * 60 * 60 * 24 * days);
const uids = await db.getSortedSetRangeByScore('usersRemote:lastCrawled', 0, -1, '-inf', timestamp);
if (!uids.length) {
winston.info('[actors/prune] No remote users to prune, all done.');
return;
}
winston.info(`[actors/prune] Found ${uids.length} remote users last crawled more than ${days} days ago`);
let deletionCount = 0;
await batch.processArray(uids, async (uids) => {
const exists = await db.exists(uids.map(uid => `userRemote:${uid}`));
const postCounts = await db.sortedSetsCard(uids.map(uid => `uid:${uid}:posts`));
await Promise.all(uids.map(async (uid, idx) => {
if (!exists[idx]) {
// id in zset but not asserted, handle and return early
await db.sortedSetRemove('usersRemote:lastCrawled', uid);
return;
}
const { followers, following } = await Actors.getLocalFollowCounts(uid);
const postCount = postCounts[idx];
if ([postCount, followers, following].every(metric => metric < 1)) {
try {
await user.deleteAccount(uid);
deletionCount += 1;
} catch (err) {
winston.error(err.stack);
}
}
}));
}, {
batch: 50,
interval: 1000,
});
winston.info(`[actors/prune] ${deletionCount} remote users pruned.`);
};

150
src/activitypub/contexts.js Normal file
View File

@@ -0,0 +1,150 @@
'use strict';
const winston = require('winston');
const db = require('../database');
const posts = require('../posts');
const topics = require('../topics');
const activitypub = module.parent.exports;
const Contexts = module.exports;
const acceptableTypes = ['Collection', 'CollectionPage', 'OrderedCollection', 'OrderedCollectionPage'];
Contexts.get = async (uid, id) => {
let context;
let type;
// Generate digest for If-None-Match if locally cached
const tid = await posts.getPostField(id, 'tid');
const headers = {};
if (tid) {
const [mainPid, pids] = await Promise.all([
topics.getTopicField(tid, 'mainPid'),
db.getSortedSetMembers(`tid:${tid}:posts`),
]);
pids.push(mainPid);
const digest = activitypub.helpers.generateDigest(new Set(pids));
headers['If-None-Match'] = `"${digest}"`;
}
try {
({ context } = await activitypub.get('uid', uid, id, { headers }));
if (!context) {
winston.verbose(`[activitypub/context] ${id} contains no context.`);
return false;
}
({ type } = await activitypub.get('uid', uid, context));
} catch (e) {
if (e.code === 'ap_get_304') {
winston.verbose(`[activitypub/context] ${id} context unchanged.`);
return { tid };
}
winston.verbose(`[activitypub/context] ${id} context not resolvable.`);
return false;
}
if (acceptableTypes.includes(type)) {
return { context };
}
return false;
};
Contexts.getItems = async (uid, id, options) => {
if (!options.hasOwnProperty('root')) {
options.root = true;
}
winston.verbose(`[activitypub/context] Retrieving context ${id}`);
let { type, items, orderedItems, first, next } = await activitypub.get('uid', uid, id);
if (!acceptableTypes.includes(type)) {
return [];
}
if (type.startsWith('Ordered') && orderedItems) {
items = orderedItems;
}
if (items) {
items = await Promise.all(items
.map(async item => (activitypub.helpers.isUri(item) ? parseString(uid, item) : parseItem(uid, item))));
items = items.filter(Boolean);
winston.verbose(`[activitypub/context] Found ${items.length} items.`);
}
const chain = new Set(items || []);
if (!next && options.root && first) {
next = first;
}
if (next) {
winston.verbose('[activitypub/context] Fetching next page...');
Array
.from(await Contexts.getItems(uid, next, {
...options,
root: false,
}))
.forEach((item) => {
chain.add(item);
});
}
// Handle special case where originating object is not actually part of the context collection
const inputId = activitypub.helpers.isUri(options.input) ? options.input : options.input.id;
const inCollection = Array.from(chain).map(p => p.pid).includes(inputId);
if (!inCollection) {
chain.add(activitypub.helpers.isUri(options.input) ?
await parseString(uid, options.input) :
await parseItem(uid, options.input));
}
return chain;
};
async function parseString(uid, item) {
const { type, id } = await activitypub.helpers.resolveLocalId(item);
const pid = type === 'post' && id ? id : item;
const postData = await posts.getPostData(pid);
if (postData) {
// Already cached
return postData;
}
// No local copy, fetch from source
try {
const object = await activitypub.get('uid', uid, pid);
winston.verbose(`[activitypub/context] Retrieved ${pid}`);
return parseItem(uid, object);
} catch (e) {
// Unresolvable, either temporarily or permanent, ignore for now.
winston.verbose(`[activitypub/context] Cannot retrieve ${pid}`);
return null;
}
}
async function parseItem(uid, item) {
const { type, id } = await activitypub.helpers.resolveLocalId(item.id);
const pid = type === 'post' && id ? id : item.id;
const postData = await posts.getPostData(pid);
if (postData) {
// Already cached
return postData;
}
// Handle activity wrapper
if (item.type === 'Create') {
item = item.object;
if (activitypub.helpers.isUri(item)) {
return parseString(uid, item);
}
} else if (!activitypub._constants.acceptedPostTypes.includes(item.type)) {
// Not a note, silently skip.
return null;
}
winston.verbose(`[activitypub/context] Parsing ${pid}`);
return await activitypub.mocks.post(item);
}

432
src/activitypub/helpers.js Normal file
View File

@@ -0,0 +1,432 @@
'use strict';
const { generateKeyPairSync } = require('crypto');
const nconf = require('nconf');
const validator = require('validator');
const cheerio = require('cheerio');
const crypto = require('crypto');
const meta = require('../meta');
const posts = require('../posts');
const categories = require('../categories');
const request = require('../request');
const db = require('../database');
const ttl = require('../cache/ttl');
const user = require('../user');
const utils = require('../utils');
const activitypub = require('.');
const webfingerRegex = /^(@|acct:)?[\w-]+@.+$/;
const webfingerCache = ttl({
max: 5000,
ttl: 1000 * 60 * 60 * 24, // 24 hours
});
const sha256 = payload => crypto.createHash('sha256').update(payload).digest('hex');
const Helpers = module.exports;
Helpers.isUri = (value) => {
if (typeof value !== 'string') {
value = String(value);
}
return validator.isURL(value, {
require_protocol: true,
require_host: true,
protocols: activitypub._constants.acceptedProtocols,
require_valid_protocol: true,
require_tld: false, // temporary — for localhost
});
};
Helpers.isWebfinger = (value) => {
// N.B. returns normalized handle, so truthy check!
if (webfingerRegex.test(value) && !Helpers.isUri(value)) {
if (value.startsWith('@')) {
return value.slice(1);
} else if (value.startsWith('acct:')) {
return value.slice(5);
}
return value;
}
return false;
};
Helpers.query = async (id) => {
const isUri = Helpers.isUri(id);
// username@host ids use acct: URI schema
const uri = isUri ? new URL(id) : new URL(`acct:${id}`);
// JS doesn't parse anything other than protocol and pathname from acct: URIs, so we need to just split id manually
let [username, hostname] = isUri ? [uri.pathname || uri.href, uri.host] : id.split('@');
if (!username || !hostname) {
return false;
}
username = username.trim();
hostname = hostname.trim();
const cached = webfingerCache.get(id);
if (cached !== undefined) {
return cached;
}
const query = new URLSearchParams({ resource: uri });
// Make a webfinger query to retrieve routing information
let response;
let body;
try {
({ response, body } = await request.get(`https://${hostname}/.well-known/webfinger?${query}`));
} catch (e) {
return false;
}
if (response.statusCode !== 200 || !body.hasOwnProperty('links')) {
return false;
}
// Parse links to find actor endpoint
let actorUri = body.links.filter(link => activitypub._constants.acceptableTypes.includes(link.type) && link.rel === 'self');
if (actorUri.length) {
actorUri = actorUri.pop();
({ href: actorUri } = actorUri);
}
const { subject, publicKey } = body;
const payload = { subject, username, hostname, actorUri, publicKey };
const claimedId = new URL(subject).pathname;
webfingerCache.set(claimedId, payload);
if (claimedId !== id) {
webfingerCache.set(id, payload);
}
return payload;
};
Helpers.generateKeys = async (type, id) => {
// winston.verbose(`[activitypub] Generating RSA key-pair for ${type} ${id}`);
const {
publicKey,
privateKey,
} = generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: {
type: 'spki',
format: 'pem',
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem',
},
});
await db.setObject(`${type}:${id}:keys`, { publicKey, privateKey });
return { publicKey, privateKey };
};
Helpers.resolveLocalId = async (input) => {
if (Helpers.isUri(input)) {
const { host, pathname, hash } = new URL(input);
if (host === nconf.get('url_parsed').host) {
const [prefix, value] = pathname.replace(nconf.get('relative_path'), '').split('/').filter(Boolean);
let activityData = {};
if (hash.startsWith('#activity')) {
const [, activity, data, timestamp] = hash.split('/', 4);
activityData = { activity, data, timestamp };
}
switch (prefix) {
case 'uid':
return { type: 'user', id: value, ...activityData };
case 'post':
return { type: 'post', id: value, ...activityData };
case 'cid':
case 'category':
return { type: 'category', id: value, ...activityData };
case 'user': {
const uid = await user.getUidByUserslug(value);
return { type: 'user', id: uid, ...activityData };
}
}
return { type: null, id: null, ...activityData };
}
return { type: null, id: null };
} else if (String(input).indexOf('@') !== -1) { // Webfinger
input = decodeURIComponent(input);
const [slug] = input.replace(/^(acct:|@)/, '').split('@');
const uid = await user.getUidByUserslug(slug);
return { type: 'user', id: uid };
}
return { type: null, id: null };
};
Helpers.resolveActor = (type, id) => {
switch (type) {
case 'user':
case 'uid': {
return `${nconf.get('url')}${id > 0 ? `/uid/${id}` : '/actor'}`;
}
case 'category':
case 'cid': {
return `${nconf.get('url')}/category/${id}`;
}
default:
throw new Error('[[error:activitypub.invalid-id]]');
}
};
Helpers.resolveActivity = async (activity, data, id, resolved) => {
switch (activity.toLowerCase()) {
case 'follow': {
const actor = await Helpers.resolveActor(resolved.type, resolved.id);
const { actorUri: targetUri } = await Helpers.query(data);
return {
'@context': 'https://www.w3.org/ns/activitystreams',
actor,
id,
type: 'Follow',
object: targetUri,
};
}
case 'announce':
case 'create': {
const object = await Helpers.resolveObjects(resolved.id);
// local create activities are assumed to come from the user who created the underlying object
const actor = object.attributedTo || object.actor;
return {
'@context': 'https://www.w3.org/ns/activitystreams',
actor,
id,
type: 'Create',
object,
};
}
default: {
throw new Error('[[error:activitypub.not-implemented]]');
}
}
};
Helpers.mapToLocalType = (type) => {
if (type === 'Person') {
return 'user';
}
if (type === 'Group') {
return 'category';
}
if (type === 'Hashtag') {
return 'tag';
}
if (activitypub._constants.acceptedPostTypes.includes(type)) {
return 'post';
}
};
Helpers.resolveObjects = async (ids) => {
if (!Array.isArray(ids)) {
ids = [ids];
}
const objects = await Promise.all(ids.map(async (id) => {
// try to get a local ID first
const { type, id: resolvedId, activity, data: activityData } = await Helpers.resolveLocalId(id);
// activity data is only resolved for local IDs - so this will be false for remote posts
if (activity) {
return Helpers.resolveActivity(activity, activityData, id, { type, id: resolvedId });
}
switch (type) {
case 'user': {
if (!await user.exists(resolvedId)) {
throw new Error('[[error:activitypub.invalid-id]]');
}
return activitypub.mocks.actors.user(resolvedId);
}
case 'post': {
const post = (await posts.getPostSummaryByPids(
[resolvedId],
activitypub._constants.uid,
{ stripTags: false }
)).pop();
if (!post) {
throw new Error('[[error:activitypub.invalid-id]]');
}
return activitypub.mocks.note(post);
}
case 'category': {
if (!await categories.exists(resolvedId)) {
throw new Error('[[error:activitypub.invalid-id]]');
}
return activitypub.mocks.actors.category(resolvedId);
}
// if the type is not recognized, assume it's not a local ID and fetch the object from its origin
default: {
return activitypub.get('uid', 0, id);
}
}
}));
return objects.length === 1 ? objects[0] : objects;
};
Helpers.generateTitle = (html) => {
// Given an html string, generates a more appropriate title if possible
const $ = cheerio.load(html);
let title;
// Try the first paragraph element
title = $('h1, h2, h3, h4, h5, h6, title, p, span').first().text();
// Fall back to newline splitting (i.e. if no paragraph elements)
title = title || html.split('\n').filter(Boolean).shift();
// Strip html
title = utils.stripHTMLTags(title);
// Split sentences and use only first one
const sentences = title
.split(/(\.|\?|!)\s/)
.reduce((memo, cur, idx, sentences) => {
if (idx % 2) {
memo.push(`${sentences[idx - 1]}${cur}`);
}
return memo;
}, []);
if (sentences.length > 1) {
title = sentences.shift();
}
// Truncate down if too long
if (title.length > meta.config.maximumTitleLength) {
title = `${title.slice(0, meta.config.maximumTitleLength - 3)}...`;
}
return title;
};
Helpers.remoteAnchorToLocalProfile = async (content) => {
const anchorRegex = /<a.*?href=['"](.+?)['"].*?>(.*?)<\/a>/ig;
const anchors = content.matchAll(anchorRegex);
const urls = new Set();
const matches = [];
for (const anchor of anchors) {
const [match, url] = anchor;
matches.push([match, url]);
urls.add(url);
}
if (!urls.size) {
return content;
}
// Filter out urls that don't backreference to a remote id
const urlsArray = Array.from(urls);
const [backrefs, urlAsIdExists] = await Promise.all([
db.getObjectFields('remoteUrl:uid', urlsArray),
db.isSortedSetMembers('usersRemote:lastCrawled', urlsArray),
]);
const urlMap = new Map();
urlsArray.forEach((url, index) => {
if (backrefs[url] || urlAsIdExists[index]) {
urlMap.set(url, backrefs[url] || url);
}
});
let slugs = await user.getUsersFields(Array.from(urlMap.values()), ['userslug']);
slugs = slugs.map(({ userslug }) => userslug);
Array.from(urlMap.keys()).forEach((url, idx) => {
urlMap.set(url, `/user/${encodeURIComponent(slugs[idx])}`);
});
// Modify existing anchors to local profile
matches.forEach(([match, href]) => {
const replacementHref = urlMap.get(href);
if (replacementHref) {
const replacement = match.replace(href, replacementHref);
content = content.split(match).join(replacement);
}
});
return content;
};
// eslint-disable-next-line max-len
Helpers.makeSet = (object, properties) => new Set(properties.reduce((memo, property) => memo.concat(Array.isArray(object[property]) ? object[property] : [object[property]]), []));
Helpers.generateCollection = async ({ set, method, page, perPage, url }) => {
if (!method) {
method = db.getSortedSetRange;
}
const count = await db.sortedSetCard(set);
const pageCount = Math.max(1, Math.ceil(count / perPage));
let items = [];
let paginate = true;
if (!page && pageCount === 1) {
page = 1;
paginate = false;
}
if (page) {
const invalidPagination = page < 1 || page > pageCount;
if (invalidPagination) {
throw new Error('[[error:invalid-data]]');
}
const start = Math.max(0, ((page - 1) * perPage) - 1);
const stop = Math.max(0, start + perPage - 1);
items = await method(set, start, stop);
}
const object = {
type: paginate && items ? 'OrderedCollectionPage' : 'OrderedCollection',
totalItems: count,
};
if (items.length) {
object.orderedItems = items;
if (paginate) {
object.partOf = url;
object.next = page < pageCount ? `${url}?page=${page + 1}` : null;
object.prev = page > 1 ? `${url}?page=${page - 1}` : null;
}
} else {
object.orderedItems = [];
}
if (paginate) {
object.first = `${url}?page=1`;
object.last = `${url}?page=${pageCount}`;
}
return object;
};
Helpers.generateDigest = (set) => {
if (!(set instanceof Set)) {
throw new Error('[[error:invalid-data]]');
}
return Array
.from(set)
.map(item => sha256(item))
.reduce((memo, cur) => {
const a = Buffer.from(memo, 'hex');
const b = Buffer.from(cur, 'hex');
// eslint-disable-next-line no-bitwise
const result = a.map((x, i) => x ^ b[i]);
return result.toString('hex');
});
};

530
src/activitypub/inbox.js Normal file
View File

@@ -0,0 +1,530 @@
'use strict';
const winston = require('winston');
const nconf = require('nconf');
const db = require('../database');
const privileges = require('../privileges');
const user = require('../user');
const posts = require('../posts');
const topics = require('../topics');
const categories = require('../categories');
const notifications = require('../notifications');
const flags = require('../flags');
const api = require('../api');
const activitypub = require('.');
const socketHelpers = require('../socket.io/helpers');
const helpers = require('./helpers');
const inbox = module.exports;
function reject(type, object, target, senderType = 'uid', id = 0) {
activitypub.send(senderType, id, target, {
id: `${helpers.resolveActor(senderType, id)}#/activity/reject/${encodeURIComponent(object.id)}`,
type: 'Reject',
object: {
type,
target,
object,
},
}).catch(err => winston.error(err.stack));
}
// FEP 1b12
async function announce(id, activity) {
let localId;
if (id.startsWith(nconf.get('url'))) {
({ id: localId } = await activitypub.helpers.resolveLocalId(id));
}
const cid = await posts.getCidByPid(localId || id);
const followers = await activitypub.notes.getCategoryFollowers(cid);
if (!followers.length) {
return;
}
const { actor } = activity;
followers.unshift(actor);
winston.info(`[activitypub/inbox.announce(1b12)] Announcing ${activity.type} to followers of cid ${cid}`);
await Promise.all([activity, activity.object].map(async (object) => {
await activitypub.send('cid', cid, followers, {
id: `${nconf.get('url')}/post/${encodeURIComponent(id)}#activity/announce/${Date.now()}`,
type: 'Announce',
to: [`${nconf.get('url')}/category/${cid}/followers`],
cc: [actor, activitypub._constants.publicAddress],
object,
});
}));
}
inbox.create = async (req) => {
const { object } = req.body;
// Temporary, reject non-public notes.
if (![...object.to, ...object.cc].includes(activitypub._constants.publicAddress)) {
throw new Error('[[error:activitypub.not-implemented]]');
}
const asserted = await activitypub.notes.assert(0, object);
if (asserted) {
announce(object.id, req.body);
}
};
inbox.update = async (req) => {
const { actor, object } = req.body;
// Origin checking
const actorHostname = new URL(actor).hostname;
const objectHostname = new URL(object.id).hostname;
if (actorHostname !== objectHostname) {
throw new Error('[[error:activitypub.origin-mismatch]]');
}
switch (object.type) {
case 'Note': {
const postData = await activitypub.mocks.post(object);
const exists = await posts.exists(object.id);
try {
if (exists) {
await posts.edit(postData);
const isDeleted = await posts.getPostField(object.id, 'deleted');
if (isDeleted) {
await api.posts.restore({ uid: actor }, { pid: object.id });
}
} else {
const asserted = await activitypub.notes.assert(0, object.id);
if (asserted) {
announce(object.id, req.body);
}
}
} catch (e) {
reject('Update', object, actor);
}
break;
}
case 'Application': // falls through
case 'Group': // falls through
case 'Organization': // falls through
case 'Service': // falls through
case 'Person': {
await activitypub.actors.assert(object.id, { update: true });
break;
}
case 'Tombstone': {
const [isNote/* , isActor */] = await Promise.all([
posts.exists(object.id),
// db.isSortedSetMember('usersRemote:lastCrawled', object.id),
]);
switch (true) {
case isNote: {
await api.posts.delete({ uid: actor }, { pid: object.id });
break;
}
// case isActor: {
// console.log('actor');
// break;
// }
}
}
}
};
inbox.delete = async (req) => {
const { actor, object } = req.body;
// Deletes don't have their objects resolved automatically
let method = 'purge';
try {
const { type } = await activitypub.get('uid', 0, object);
if (type === 'Tombstone') {
method = 'delete';
}
} catch (e) {
// probably 410/404
}
// Origin checking
const actorHostname = new URL(actor).hostname;
const objectHostname = new URL(object).hostname;
if (actorHostname !== objectHostname) {
throw new Error('[[error:activitypub.origin-mismatch]]');
}
const [isNote/* , isActor */] = await Promise.all([
posts.exists(object),
// db.isSortedSetMember('usersRemote:lastCrawled', object.id),
]);
switch (true) {
case isNote: {
const uid = await posts.getPostField(object, 'uid');
await announce(object, req.body);
await api.posts[method]({ uid }, { pid: object });
break;
}
// case isActor: {
// console.log('actor');
// break;
// }
default: {
// winston.verbose(`[activitypub/inbox.delete] Object (${object}) does not exist locally. Doing nothing.`);
break;
}
}
};
inbox.like = async (req) => {
const { actor, object } = req.body;
const { type, id } = await activitypub.helpers.resolveLocalId(object.id);
if (type !== 'post' || !(await posts.exists(id))) {
return reject('Like', object, actor);
}
const allowed = await privileges.posts.can('posts:upvote', id, activitypub._constants.uid);
if (!allowed) {
winston.verbose(`[activitypub/inbox.like] ${id} not allowed to be upvoted.`);
return reject('Like', object, actor);
}
winston.verbose(`[activitypub/inbox/like] id ${id} via ${actor}`);
const result = await posts.upvote(id, actor);
announce(object.id, req.body);
socketHelpers.upvote(result, 'notifications:upvoted-your-post-in');
};
inbox.announce = async (req) => {
const { actor, object, published, to, cc } = req.body;
let timestamp = new Date(published);
timestamp = timestamp.toString() !== 'Invalid Date' ? timestamp.getTime() : Date.now();
const assertion = await activitypub.actors.assert(actor);
if (!assertion) {
throw new Error('[[error:activitypub.invalid-id]]');
}
let tid;
let pid;
const { cids } = await activitypub.actors.getLocalFollowers(actor);
let cid = null;
if (cids.size > 0) {
cid = Array.from(cids)[0];
}
if (String(object.id).startsWith(nconf.get('url'))) { // Local object
const { type, id } = await activitypub.helpers.resolveLocalId(object.id);
if (type !== 'post' || !(await posts.exists(id))) {
throw new Error('[[error:activitypub.invalid-id]]');
}
pid = id;
tid = await posts.getPostField(id, 'tid');
socketHelpers.sendNotificationToPostOwner(pid, actor, 'announce', 'notifications:activitypub.announce');
} else { // Remote object
// Follower check
if (!cid) {
const { followers } = await activitypub.actors.getLocalFollowCounts(actor);
if (!followers) {
winston.verbose(`[activitypub/inbox.announce] Rejecting ${object.id} via ${actor} due to no followers`);
reject('Announce', object, actor);
return;
}
}
// Handle case where Announce(Create(Note-ish)) is received
if (object.type === 'Create' && activitypub._constants.acceptedPostTypes.includes(object.object.type)) {
pid = object.object.id;
} else {
pid = object.id;
}
pid = await activitypub.resolveId(0, pid); // in case wrong id is passed-in; unlikely, but still.
if (!pid) {
return;
}
({ tid } = await activitypub.notes.assert(0, pid, { cid, skipChecks: true })); // checks skipped; done above.
if (!tid) {
return;
}
await topics.updateLastPostTime(tid, timestamp);
await activitypub.notes.updateLocalRecipients(pid, { to, cc });
await activitypub.notes.syncUserInboxes(tid);
}
winston.verbose(`[activitypub/inbox/announce] Parsing id ${pid}`);
if (!cid) { // Topic events from actors followed by users only
await activitypub.notes.announce.add(pid, actor, timestamp);
}
};
inbox.follow = async (req) => {
const { actor, object, id: followId } = req.body;
// Sanity checks
const { type, id } = await helpers.resolveLocalId(object.id);
if (!['category', 'user'].includes(type)) {
throw new Error('[[error:activitypub.invalid-id]]');
}
const assertion = await activitypub.actors.assert(actor);
if (!assertion) {
throw new Error('[[error:activitypub.invalid-id]]');
}
const handle = await user.getUserField(actor, 'username');
if (type === 'user') {
const [exists, allowed] = await Promise.all([
user.exists(id),
privileges.global.can('view:users', activitypub._constants.uid),
]);
if (!exists || !allowed) {
throw new Error('[[error:invalid-uid]]');
}
const isFollowed = await inbox.isFollowed(actor, id);
if (isFollowed) {
// No additional parsing required
return;
}
const now = Date.now();
await db.sortedSetAdd(`followersRemote:${id}`, now, actor);
await db.sortedSetAdd(`followingRemote:${actor}`, now, id); // for following backreference (actor pruning)
const followerRemoteCount = await db.sortedSetCard(`followersRemote:${id}`);
await user.setUserField(id, 'followerRemoteCount', followerRemoteCount);
await user.onFollow(actor, id);
activitypub.send('uid', id, actor, {
id: `${nconf.get('url')}/${type}/${id}#activity/accept:follow/${handle}/${Date.now()}`,
type: 'Accept',
object: {
id: followId,
type: 'Follow',
actor,
object: object.id,
},
}).catch(err => winston.error(err.stack));
} else if (type === 'category') {
const [exists, allowed] = await Promise.all([
categories.exists(id),
privileges.categories.can('read', id, activitypub._constants.uid),
]);
if (!exists) {
throw new Error('[[error:invalid-cid]]');
}
if (!allowed) {
return reject('Follow', object, actor);
}
const watchState = await categories.getWatchState([id], actor);
if (watchState[0] !== categories.watchStates.tracking) {
await user.setCategoryWatchState(actor, id, categories.watchStates.tracking);
}
activitypub.send('cid', id, actor, {
id: `${nconf.get('url')}/${type}/${id}#activity/accept:follow/${handle}/${Date.now()}`,
type: 'Accept',
object: {
id: followId,
type: 'Follow',
actor,
object: object.id,
},
}).catch(err => winston.error(err.stack));
}
};
inbox.isFollowed = async (actorId, uid) => {
if (actorId.indexOf('@') === -1 || parseInt(uid, 10) <= 0) {
return false;
}
return await db.isSortedSetMember(`followersRemote:${uid}`, actorId);
};
inbox.accept = async (req) => {
const { actor, object } = req.body;
const { type } = object;
const { type: localType, id } = await helpers.resolveLocalId(object.actor);
if (!['user', 'category'].includes(localType)) {
throw new Error('[[error:invalid-data]]');
}
const assertion = await activitypub.actors.assert(actor);
if (!assertion) {
throw new Error('[[error:activitypub.invalid-id]]');
}
if (type === 'Follow') {
if (localType === 'user') {
if (!await db.isSortedSetMember(`followRequests:uid.${id}`, actor)) {
if (await db.isSortedSetMember(`followingRemote:${id}`, actor)) return; // already following
return reject('Accept', req.body, actor); // not following, not requested, so reject to hopefully stop retries
}
const timestamp = await db.sortedSetScore(`followRequests:uid.${id}`, actor);
await Promise.all([
db.sortedSetRemove(`followRequests:uid.${id}`, actor),
db.sortedSetAdd(`followingRemote:${id}`, timestamp, actor),
db.sortedSetAdd(`followersRemote:${actor}`, timestamp, id), // for followers backreference and notes assertion checking
]);
const followingRemoteCount = await db.sortedSetCard(`followingRemote:${id}`);
await user.setUserField(id, 'followingRemoteCount', followingRemoteCount);
} else if (localType === 'category') {
if (!await db.isSortedSetMember(`followRequests:cid.${id}`, actor)) {
if (await db.isSortedSetMember(`cid:${id}:following`, actor)) return; // already following
return reject('Accept', req.body, actor); // not following, not requested, so reject to hopefully stop retries
}
const timestamp = await db.sortedSetScore(`followRequests:cid.${id}`, actor);
await Promise.all([
db.sortedSetRemove(`followRequests:cid.${id}`, actor),
db.sortedSetAdd(`cid:${id}:following`, timestamp, actor),
db.sortedSetAdd(`followersRemote:${actor}`, timestamp, `cid|${id}`), // for notes assertion checking
]);
}
}
};
inbox.undo = async (req) => {
// todo: "actor" in this case should be the one in object, no?
const { actor, object } = req.body;
const { type } = object;
if (actor !== object.actor) {
throw new Error('[[error:activitypub.actor-mismatch]]');
}
const assertion = await activitypub.actors.assert(actor);
if (!assertion) {
throw new Error('[[error:activitypub.invalid-id]]');
}
let { type: localType, id } = await helpers.resolveLocalId(object.object);
winston.verbose(`[activitypub/inbox/undo] ${type} ${localType && id ? `${localType} ${id}` : object.object} via ${actor}`);
switch (type) {
case 'Follow': {
switch (localType) {
case 'user': {
const exists = await user.exists(id);
if (!exists) {
throw new Error('[[error:invalid-uid]]');
}
await Promise.all([
db.sortedSetRemove(`followersRemote:${id}`, actor),
db.sortedSetRemove(`followingRemote:${actor}`, id),
]);
const followerRemoteCount = await db.sortedSetCard(`followerRemote:${id}`);
await user.setUserField(id, 'followerRemoteCount', followerRemoteCount);
notifications.rescind(`follow:${id}:uid:${actor}`);
break;
}
case 'category': {
const exists = await categories.exists(id);
if (!exists) {
throw new Error('[[error:invalid-cid]]');
}
await user.setCategoryWatchState(actor, id, categories.watchStates.notwatching);
break;
}
}
break;
}
case 'Like': {
const exists = await posts.exists(id);
if (localType !== 'post' || !exists) {
throw new Error('[[error:invalid-pid]]');
}
const allowed = await privileges.posts.can('posts:upvote', id, activitypub._constants.uid);
if (!allowed) {
winston.verbose(`[activitypub/inbox.like] ${id} not allowed to be upvoted.`);
reject('Like', object, actor);
break;
}
await posts.unvote(id, actor);
announce(object.object, req.body);
notifications.rescind(`upvote:post:${id}:uid:${actor}`);
break;
}
case 'Announce': {
id = id || object.object; // remote announces
const exists = await posts.exists(id);
if (!exists) {
// winston.verbose(
// `[activitypub/inbox/undo] Attempted to undo announce of ${id} but couldn't find it, so doing nothing.
// `);
break;
}
await activitypub.notes.announce.remove(id, actor);
notifications.rescind(`announce:post:${id}:uid:${actor}`);
break;
}
case 'Flag': {
if (!Array.isArray(object.object)) {
object.object = [object.object];
}
await Promise.all(object.object.map(async (subject) => {
const { type, id } = await activitypub.helpers.resolveLocalId(subject.id);
try {
await flags.rescindReport(type, id, actor);
} catch (e) {
reject('Undo', { type: 'Flag', object: [subject] }, actor);
}
}));
break;
}
}
};
inbox.flag = async (req) => {
const { actor, object, content } = req.body;
const objects = Array.isArray(object) ? object : [object];
// Check if the actor is valid
if (!await activitypub.actors.assert(actor)) {
return reject('Flag', objects, actor);
}
await Promise.all(objects.map(async (subject, index) => {
const { type, id } = await activitypub.helpers.resolveObjects(subject.id);
try {
await flags.create(activitypub.helpers.mapToLocalType(type), id, actor, content);
} catch (e) {
reject('Flag', objects[index], actor);
}
}));
};
inbox.reject = async (req) => {
const { actor, object } = req.body;
const { type, id } = object;
const { hostname } = new URL(actor);
const queueId = `${type}:${id}:${hostname}`;
// stop retrying rejected requests
clearTimeout(activitypub.retryQueue.get(queueId));
activitypub.retryQueue.delete(queueId);
};

381
src/activitypub/index.js Normal file
View File

@@ -0,0 +1,381 @@
'use strict';
const nconf = require('nconf');
const winston = require('winston');
const { createHash, createSign, createVerify, getHashes } = require('crypto');
const { CronJob } = require('cron');
const request = require('../request');
const db = require('../database');
const meta = require('../meta');
const user = require('../user');
const utils = require('../utils');
const ttl = require('../cache/ttl');
const lru = require('../cache/lru');
const batch = require('../batch');
const pubsub = require('../pubsub');
const analytics = require('../analytics');
const requestCache = ttl({
max: 5000,
ttl: 1000 * 60 * 5, // 5 minutes
});
const ActivityPub = module.exports;
ActivityPub._constants = Object.freeze({
uid: -2,
publicAddress: 'https://www.w3.org/ns/activitystreams#Public',
acceptableTypes: [
'application/activity+json',
'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
],
acceptedPostTypes: [
'Note', 'Page', 'Article', 'Question',
],
acceptableActorTypes: new Set(['Application', 'Group', 'Organization', 'Person', 'Service']),
requiredActorProps: ['inbox', 'outbox'],
acceptedProtocols: ['https', ...(process.env.CI === 'true' ? ['http'] : [])],
});
ActivityPub._cache = requestCache;
ActivityPub.helpers = require('./helpers');
ActivityPub.inbox = require('./inbox');
ActivityPub.mocks = require('./mocks');
ActivityPub.notes = require('./notes');
ActivityPub.contexts = require('./contexts');
ActivityPub.actors = require('./actors');
ActivityPub.instances = require('./instances');
ActivityPub.startJobs = () => {
// winston.verbose('[activitypub/jobs] Registering jobs.');
new CronJob('0 0 * * *', async () => {
try {
await ActivityPub.notes.prune();
} catch (err) {
winston.error(err.stack);
}
}, null, true, null, null, false); // change last argument to true for debugging
new CronJob('0 1 * * *', async () => {
try {
await ActivityPub.actors.prune();
} catch (err) {
winston.error(err.stack);
}
}, null, true, null, null, false); // change last argument to true for debugging
};
ActivityPub.resolveId = async (uid, id) => {
try {
const query = new URL(id);
({ id } = await ActivityPub.get('uid', uid, id));
const response = new URL(id);
if (query.host !== response.host) {
winston.warn(`[activitypub/resolveId] id resolution domain mismatch: ${query.href} != ${response.href}`);
return null;
}
return id;
} catch (e) {
return null;
}
};
ActivityPub.resolveInboxes = async (ids) => {
const inboxes = new Set();
if (!meta.config.activitypubAllowLoopback) {
ids = ids.filter((id) => {
const { hostname } = new URL(id);
return hostname !== nconf.get('url_parsed').hostname;
});
}
await ActivityPub.actors.assert(ids);
await batch.processArray(ids, async (currentIds) => {
const usersData = await user.getUsersFields(currentIds, ['inbox', 'sharedInbox']);
usersData.forEach((u) => {
if (u && (u.sharedInbox || u.inbox)) {
inboxes.add(u.sharedInbox || u.inbox);
}
});
}, {
batch: 500,
});
return Array.from(inboxes);
};
ActivityPub.getPublicKey = async (type, id) => {
let publicKey;
try {
({ publicKey } = await db.getObject(`${type}:${id}:keys`));
} catch (e) {
({ publicKey } = await ActivityPub.helpers.generateKeys(type, id));
}
return publicKey;
};
ActivityPub.getPrivateKey = async (type, id) => {
// Sanity checking
if (!['cid', 'uid'].includes(type) || !utils.isNumber(id) || parseInt(id, 10) < 0) {
throw new Error('[[error:invalid-data]]');
}
id = parseInt(id, 10);
let privateKey;
try {
({ privateKey } = await db.getObject(`${type}:${id}:keys`));
} catch (e) {
({ privateKey } = await ActivityPub.helpers.generateKeys(type, id));
}
let keyId;
if (type === 'uid') {
keyId = `${nconf.get('url')}${id > 0 ? `/uid/${id}` : '/actor'}#key`;
} else {
keyId = `${nconf.get('url')}/category/${id}#key`;
}
return { key: privateKey, keyId };
};
ActivityPub.fetchPublicKey = async (uri) => {
// Used for retrieving the public key from the passed-in keyId uri
const body = await ActivityPub.get('uid', 0, uri);
if (!body.hasOwnProperty('publicKey')) {
throw new Error('[[error:activitypub.pubKey-not-found]]');
}
return body.publicKey;
};
ActivityPub.sign = async ({ key, keyId }, url, payload) => {
// Returns string for use in 'Signature' header
const { host, pathname } = new URL(url);
const date = new Date().toUTCString();
let digest = null;
let headers = '(request-target) host date';
let signed_string = `(request-target): ${payload ? 'post' : 'get'} ${pathname}\nhost: ${host}\ndate: ${date}`;
// Calculate payload hash if payload present
if (payload) {
const payloadHash = createHash('sha256');
payloadHash.update(JSON.stringify(payload));
digest = `SHA-256=${payloadHash.digest('base64')}`;
headers += ' digest';
signed_string += `\ndigest: ${digest}`;
}
// Sign string using private key
let signature = createSign('sha256');
signature.update(signed_string);
signature.end();
signature = signature.sign(key, 'base64');
// Construct signature header
return {
date,
digest,
signature: `keyId="${keyId}",headers="${headers}",signature="${signature}",algorithm="hs2019"`,
};
};
ActivityPub.verify = async (req) => {
// winston.verbose('[activitypub/verify] Starting signature verification...');
if (!req.headers.hasOwnProperty('signature')) {
// winston.verbose('[activitypub/verify] Failed, no signature header.');
return false;
}
// Break the signature apart
let { keyId, headers, signature, algorithm, created, expires } = req.headers.signature.split(',').reduce((memo, cur) => {
const split = cur.split('="');
const key = split.shift();
const value = split.join('="');
memo[key] = value.slice(0, -1);
return memo;
}, {});
const acceptableHashes = getHashes();
if (algorithm === 'hs2019' || !acceptableHashes.includes(algorithm)) {
algorithm = 'sha256';
}
// Re-construct signature string
const signed_string = headers.split(' ').reduce((memo, cur) => {
switch (cur) {
case '(request-target)': {
memo.push(`${cur}: ${String(req.method).toLowerCase()} ${req.baseUrl}${req.path}`);
break;
}
case '(created)': {
memo.push(`${cur}: ${created}`);
break;
}
case '(expires)': {
memo.push(`${cur}: ${expires}`);
break;
}
default: {
memo.push(`${cur}: ${req.headers[cur]}`);
break;
}
}
return memo;
}, []).join('\n');
// Verify the signature string via public key
try {
// Retrieve public key from remote instance
// winston.verbose(`[activitypub/verify] Retrieving pubkey for ${keyId}`);
const { publicKeyPem } = await ActivityPub.fetchPublicKey(keyId);
const verify = createVerify('sha256');
verify.update(signed_string);
verify.end();
// winston.verbose('[activitypub/verify] Attempting signed string verification');
const verified = verify.verify(publicKeyPem, signature, 'base64');
return verified;
} catch (e) {
// winston.verbose('[activitypub/verify] Failed, key retrieval or verification failure.');
return false;
}
};
ActivityPub.get = async (type, id, uri, options) => {
options = {
cache: true,
...options,
};
const cacheKey = [id, uri].join(';');
const cached = requestCache.get(cacheKey);
if (options.cache && cached !== undefined) {
return cached;
}
const keyData = await ActivityPub.getPrivateKey(type, id);
const headers = id >= 0 ? await ActivityPub.sign(keyData, uri) : {};
// winston.verbose(`[activitypub/get] ${uri}`);
try {
const { response, body } = await request.get(uri, {
headers: {
...headers,
...options.headers,
Accept: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
},
timeout: 5000,
});
if (!String(response.statusCode).startsWith('2')) {
winston.verbose(`[activitypub/get] Received ${response.statusCode} when querying ${uri}`);
if (body.hasOwnProperty('error')) {
winston.verbose(`[activitypub/get] Error received: ${body.error}`);
}
const e = new Error(`[[error:activitypub.get-failed]]`);
e.code = `ap_get_${response.statusCode}`;
throw e;
}
requestCache.set(cacheKey, body);
return body;
} catch (e) {
if (String(e.code).startsWith('ap_get_')) {
throw e;
}
// Handle things like non-json body, etc.
const { cause } = e;
throw new Error(`[[error:activitypub.get-failed]]`, { cause });
}
};
ActivityPub.retryQueue = lru({ name: 'activitypub-retry-queue', max: 4000, ttl: 1000 * 60 * 60 * 24 * 60 });
// handle clearing retry queue from another member of the cluster
pubsub.on(`activitypub-retry-queue:lruCache:del`, (keys) => {
if (Array.isArray(keys)) {
keys.forEach(key => clearTimeout(ActivityPub.retryQueue.get(key)));
}
});
async function sendMessage(uri, id, type, payload, attempts = 1) {
const keyData = await ActivityPub.getPrivateKey(type, id);
const headers = await ActivityPub.sign(keyData, uri, payload);
// winston.verbose(`[activitypub/send] ${uri}`);
try {
const { response, body } = await request.post(uri, {
headers: {
...headers,
'content-type': 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
},
body: payload,
});
if (String(response.statusCode).startsWith('2')) {
// winston.verbose(`[activitypub/send] Successfully sent ${payload.type} to ${uri}`);
} else {
throw new Error(String(body));
}
} catch (e) {
winston.warn(`[activitypub/send] Could not send ${payload.type} to ${uri}; error: ${e.message}`);
// add to retry queue
if (attempts < 12) { // stop attempting after ~2 months
const timeout = (4 ** attempts) * 1000; // exponential backoff
const queueId = `${payload.type}:${payload.id}:${new URL(uri).hostname}`;
const timeoutId = setTimeout(() => sendMessage(uri, id, type, payload, attempts + 1), timeout);
ActivityPub.retryQueue.set(queueId, timeoutId);
// winston.verbose(`[activitypub/send] Added ${payload.type} to ${uri} to retry queue for ${timeout}ms`);
} else {
winston.warn(`[activitypub/send] Max attempts reached for ${payload.type} to ${uri}; giving up on sending`);
}
}
}
ActivityPub.send = async (type, id, targets, payload) => {
if (!Array.isArray(targets)) {
targets = [targets];
}
const inboxes = await ActivityPub.resolveInboxes(targets);
const actor = ActivityPub.helpers.resolveActor(type, id);
payload = {
'@context': 'https://www.w3.org/ns/activitystreams',
actor,
...payload,
};
await batch.processArray(
inboxes,
async inboxBatch => Promise.all(inboxBatch.map(async uri => sendMessage(uri, id, type, payload))),
{
batch: 50,
interval: 100,
},
);
};
ActivityPub.record = async ({ id, type, actor }) => {
const now = Date.now();
const { hostname } = new URL(actor);
await Promise.all([
db.sortedSetAdd(`activities:datetime`, now, id),
db.sortedSetAdd('domains:lastSeen', now, hostname),
analytics.increment(['activities', `activities:byType:${type}`, `activities:byHost:${hostname}`]),
]);
};

View File

@@ -0,0 +1,19 @@
'use strict';
const meta = require('../meta');
const db = require('../database');
const Instances = module.exports;
Instances.log = async (domain) => {
await db.sortedSetAdd('instances:lastSeen', Date.now(), domain);
};
Instances.getCount = async () => db.sortedSetCard('instances:lastSeen');
Instances.isAllowed = async (domain) => {
let { activitypubFilter: type, activitypubFilterList: list } = meta.config;
list = new Set(String(list).split('\n'));
// eslint-disable-next-line no-bitwise
return list.has(domain) ^ !type;
};

444
src/activitypub/mocks.js Normal file
View File

@@ -0,0 +1,444 @@
'use strict';
const nconf = require('nconf');
const mime = require('mime');
const path = require('path');
const sanitize = require('sanitize-html');
const meta = require('../meta');
const user = require('../user');
const categories = require('../categories');
const posts = require('../posts');
const topics = require('../topics');
const plugins = require('../plugins');
const slugify = require('../slugify');
const utils = require('../utils');
const activitypub = module.parent.exports;
const Mocks = module.exports;
/**
* A more restrictive html sanitization run on top of standard sanitization from core.
* Done so the output HTML is stripped of all non-essential items; mainly classes from plugins..
*/
const sanitizeConfig = {
allowedTags: sanitize.defaults.allowedTags.concat(['img']),
allowedClasses: {
'*': [],
},
};
Mocks.profile = async (actors, hostMap) => {
// Should only ever be called by activitypub.actors.assert
const profiles = await Promise.all(actors.map(async (actor) => {
if (!actor) {
return null;
}
const uid = actor.id;
let hostname = hostMap.get(uid);
let {
url, preferredUsername, published, icon, image,
name, summary, followers, inbox, endpoints,
} = actor;
preferredUsername = slugify(preferredUsername || name);
const { followers: followerCount, following: followingCount } = await activitypub.actors.getLocalFollowCounts(uid);
if (!hostname) { // if not available via webfinger, infer from id
try {
({ hostname } = new URL(actor.id));
} catch (e) {
return null;
}
}
let picture;
if (icon) {
picture = typeof icon === 'string' ? icon : icon.url;
}
const iconBackgrounds = await user.getIconBackgrounds();
let bgColor = Array.prototype.reduce.call(preferredUsername, (cur, next) => cur + next.charCodeAt(), 0);
bgColor = iconBackgrounds[bgColor % iconBackgrounds.length];
const payload = {
uid,
username: `${preferredUsername}@${hostname}`,
userslug: `${preferredUsername}@${hostname}`,
displayname: name,
fullname: name,
joindate: new Date(published).getTime() || Date.now(),
picture,
status: 'offline',
'icon:text': (preferredUsername[0] || '').toUpperCase(),
'icon:bgColor': bgColor,
uploadedpicture: undefined,
'cover:url': !image || typeof image === 'string' ? image : image.url,
'cover:position': '50% 50%',
aboutme: summary,
followerCount,
followingCount,
url,
inbox,
sharedInbox: endpoints ? endpoints.sharedInbox : null,
followersUrl: followers,
};
return payload;
}));
return profiles;
};
Mocks.post = async (objects) => {
let single = false;
if (!Array.isArray(objects)) {
single = true;
objects = [objects];
}
const actorIds = new Set(objects.map(object => object.attributedTo).filter(Boolean));
await activitypub.actors.assert(Array.from(actorIds));
const posts = await Promise.all(objects.map(async (object) => {
if (!activitypub._constants.acceptedPostTypes.includes(object.type)) {
return null;
}
let {
id: pid,
url,
attributedTo: uid,
inReplyTo: toPid,
published, updated, name, content, source,
to, cc, audience, attachment, tag,
// conversation, // mastodon-specific, ignored.
} = object;
const resolved = await activitypub.helpers.resolveLocalId(toPid);
if (resolved.type === 'post') {
toPid = resolved.id;
}
const timestamp = new Date(published).getTime();
let edited = new Date(updated);
edited = Number.isNaN(edited.valueOf()) ? undefined : edited;
if (content && content.length) {
content = sanitize(content, sanitizeConfig);
content = await activitypub.helpers.remoteAnchorToLocalProfile(content);
} else {
content = '<em>This post did not contain any content.</em>';
}
const payload = {
uid,
pid,
// tid, --> purposely omitted
name,
content,
sourceContent: source && source.mediaType === 'text/markdown' ? source.content : undefined,
timestamp,
toPid,
edited,
editor: edited ? uid : undefined,
_activitypub: { to, cc, audience, attachment, tag, url },
};
return payload;
}));
return single ? posts.pop() : posts;
};
Mocks.actors = {};
Mocks.actors.user = async (uid) => {
let { username, userslug, displayname, fullname, joindate, aboutme, picture, 'cover:url': cover } = await user.getUserData(uid);
const publicKey = await activitypub.getPublicKey('uid', uid);
if (picture) {
const imagePath = await user.getLocalAvatarPath(uid);
picture = {
type: 'Image',
mediaType: mime.getType(imagePath),
url: `${nconf.get('url')}${picture}`,
};
}
if (cover) {
const imagePath = await user.getLocalCoverPath(uid);
cover = {
type: 'Image',
mediaType: mime.getType(imagePath),
url: `${nconf.get('url')}${cover}`,
};
}
return {
'@context': [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
],
id: `${nconf.get('url')}/uid/${uid}`,
url: `${nconf.get('url')}/user/${userslug}`,
followers: `${nconf.get('url')}/uid/${uid}/followers`,
following: `${nconf.get('url')}/uid/${uid}/following`,
inbox: `${nconf.get('url')}/uid/${uid}/inbox`,
outbox: `${nconf.get('url')}/uid/${uid}/outbox`,
type: 'Person',
name: username !== displayname ? fullname : username, // displayname is escaped, fullname is not
preferredUsername: userslug,
summary: aboutme,
icon: picture,
image: cover,
published: new Date(joindate).toISOString(),
publicKey: {
id: `${nconf.get('url')}/uid/${uid}#key`,
owner: `${nconf.get('url')}/uid/${uid}`,
publicKeyPem: publicKey,
},
endpoints: {
sharedInbox: `${nconf.get('url')}/inbox`,
},
};
};
Mocks.actors.category = async (cid) => {
const {
name, handle: preferredUsername, slug,
descriptionParsed: summary, backgroundImage,
} = await categories.getCategoryData(cid);
const publicKey = await activitypub.getPublicKey('cid', cid);
let image;
if (backgroundImage) {
const filename = path.basename(utils.decodeHTMLEntities(backgroundImage));
image = {
type: 'Image',
mediaType: mime.getType(filename),
url: `${nconf.get('url')}${utils.decodeHTMLEntities(backgroundImage)}`,
};
}
let icon = meta.config['brand:logo'] || `${nconf.get('relative_path')}/assets/logo.png`;
const filename = path.basename(utils.decodeHTMLEntities(icon));
icon = {
type: 'Image',
mediaType: mime.getType(filename),
url: `${nconf.get('url')}${utils.decodeHTMLEntities(icon)}`,
};
return {
'@context': [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
],
id: `${nconf.get('url')}/category/${cid}`,
url: `${nconf.get('url')}/category/${slug}`,
// followers: ,
// following: ,
inbox: `${nconf.get('url')}/category/${cid}/inbox`,
outbox: `${nconf.get('url')}/category/${cid}/outbox`,
type: 'Group',
name,
preferredUsername,
summary,
image,
icon,
publicKey: {
id: `${nconf.get('url')}/category/${cid}#key`,
owner: `${nconf.get('url')}/category/${cid}`,
publicKeyPem: publicKey,
},
endpoints: {
sharedInbox: `${nconf.get('url')}/inbox`,
},
};
};
Mocks.note = async (post) => {
const id = `${nconf.get('url')}/post/${post.pid}`;
// Return a tombstone for a deleted post
if (post.deleted === true) {
return Mocks.tombstone({
id,
formerType: 'Note',
attributedTo: `${nconf.get('url')}/uid/${post.user.uid}`,
context: `${nconf.get('url')}/topic/${post.topic.tid}`,
audience: `${nconf.get('url')}/category/${post.category.cid}`,
});
}
const published = new Date(parseInt(post.timestamp, 10)).toISOString();
// todo: post visibility
const to = new Set([activitypub._constants.publicAddress]);
const cc = new Set([`${nconf.get('url')}/uid/${post.user.uid}/followers`]);
let inReplyTo = null;
let tag = null;
let followersUrl;
let name = null;
({ titleRaw: name } = await topics.getTopicFields(post.tid, ['title']));
if (post.toPid) { // direct reply
inReplyTo = utils.isNumber(post.toPid) ? `${nconf.get('url')}/post/${post.toPid}` : post.toPid;
name = `Re: ${name}`;
const parentId = await posts.getPostField(post.toPid, 'uid');
followersUrl = await user.getUserField(parentId, 'followersUrl');
to.add(utils.isNumber(parentId) ? `${nconf.get('url')}/uid/${parentId}` : parentId);
} else if (!post.isMainPost) { // reply to OP
inReplyTo = utils.isNumber(post.topic.mainPid) ? `${nconf.get('url')}/post/${post.topic.mainPid}` : post.topic.mainPid;
name = `Re: ${name}`;
to.add(utils.isNumber(post.topic.uid) ? `${nconf.get('url')}/uid/${post.topic.uid}` : post.topic.uid);
followersUrl = await user.getUserField(post.topic.uid, 'followersUrl');
} else { // new topic
tag = post.topic.tags.map(tag => ({
type: 'Hashtag',
href: `${nconf.get('url')}/tags/${tag.valueEncoded}`,
name: `#${tag.value}`,
}));
}
if (followersUrl) {
cc.add(followersUrl);
}
const content = await posts.getPostField(post.pid, 'content');
post.content = content; // re-send raw content
const parsed = await posts.parsePost(post, 'activitypub.note');
post.content = sanitize(parsed.content, sanitizeConfig);
post.content = posts.relativeToAbsolute(post.content, posts.urlRegex);
post.content = posts.relativeToAbsolute(post.content, posts.imgRegex);
let source = null;
const [markdownEnabled, mentionsEnabled] = await Promise.all([
plugins.isActive('nodebb-plugin-markdown'),
plugins.isActive('nodebb-plugin-mentions'),
]);
if (markdownEnabled) {
const raw = await posts.getPostField(post.pid, 'content');
source = {
content: raw,
mediaType: 'text/markdown',
};
}
if (mentionsEnabled) {
const mentions = require.main.require('nodebb-plugin-mentions');
const matches = await mentions.getMatches(post.content);
if (matches.size) {
tag = tag || [];
tag.push(...Array.from(matches).map(({ type, id: href, slug: name }) => {
if (utils.isNumber(href)) { // local ref
name = name.toLowerCase(); // local slugs are always lowercase
href = `${nconf.get('url')}/${type === 'uid' ? 'user' : `category/${href}`}/${name.slice(1)}`;
name = `${name}@${nconf.get('url_parsed').hostname}`;
}
return {
type: 'Mention',
href,
name,
};
}));
Array.from(matches)
.reduce((ids, { id }) => {
if (!utils.isNumber(id) && !to.has(id) && !cc.has(id)) {
ids.push(id);
}
return ids;
}, [])
.forEach(id => cc.add(id));
}
}
let attachment = await posts.attachments.get(post.pid) || [];
const uploads = await posts.uploads.listWithSizes(post.pid);
uploads.forEach(({ name, width, height }) => {
const mediaType = mime.getType(name);
const url = `${nconf.get('url') + nconf.get('upload_url')}/${name}`;
attachment.push({ mediaType, url, width, height });
});
// Inspect post content for external imagery as well
let match = posts.imgRegex.regex.exec(post.content);
while (match !== null) {
if (match[1]) {
const { hostname, pathname, href: url } = new URL(match[1]);
if (hostname !== nconf.get('url_parsed').hostname) {
const mediaType = mime.getType(pathname);
attachment.push({ mediaType, url });
}
}
match = posts.imgRegex.regex.exec(post.content);
}
attachment = attachment.map(({ mediaType, url, width, height }) => {
let type;
switch (true) {
case mediaType.startsWith('image'): {
type = 'Image';
break;
}
default: {
type = 'Link';
break;
}
}
const payload = { type, mediaType, url };
if (width || height) {
payload.width = width;
payload.height = height;
}
return payload;
});
const object = {
'@context': 'https://www.w3.org/ns/activitystreams',
id,
type: 'Note',
to: Array.from(to),
cc: Array.from(cc),
inReplyTo,
published,
url: id,
attributedTo: `${nconf.get('url')}/uid/${post.user.uid}`,
context: `${nconf.get('url')}/topic/${post.topic.tid}`,
audience: `${nconf.get('url')}/category/${post.category.cid}`,
sensitive: false, // todo
summary: null,
name,
content: post.content,
source,
tag,
attachment,
replies: `${id}/replies`,
};
return object;
};
Mocks.tombstone = async properties => ({
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'Tombstone',
...properties,
});

450
src/activitypub/notes.js Normal file
View File

@@ -0,0 +1,450 @@
'use strict';
const winston = require('winston');
const nconf = require('nconf');
const db = require('../database');
const batch = require('../batch');
const meta = require('../meta');
const privileges = require('../privileges');
const categories = require('../categories');
const user = require('../user');
const topics = require('../topics');
const posts = require('../posts');
const utils = require('../utils');
const activitypub = module.parent.exports;
const Notes = module.exports;
async function lock(value) {
const count = await db.incrObjectField('locks', value);
return count <= 1;
}
async function unlock(value) {
await db.deleteObjectField('locks', value);
}
Notes.assert = async (uid, input, options = { skipChecks: false }) => {
/**
* Given the id or object of any as:Note, either retrieves the full context (if resolvable),
* or traverses up the reply chain to build a context.
*/
if (!input) {
return null;
}
const id = !activitypub.helpers.isUri(input) ? input.id : input;
const lockStatus = await lock(id, '[[error:activitypub.already-asserting]]');
if (!lockStatus) { // unable to achieve lock, stop processing.
return null;
}
let chain;
const context = await activitypub.contexts.get(uid, id);
if (context.tid) {
unlock(id);
const { tid } = context;
return { tid, count: 0 };
} else if (context.context) {
chain = Array.from(await activitypub.contexts.getItems(uid, context.context, { input }));
} else {
// Fall back to inReplyTo traversal
chain = Array.from(await Notes.getParentChain(uid, input));
}
if (!chain.length) {
unlock(id);
return null;
}
// Reorder chain items by timestamp
chain = chain.sort((a, b) => a.timestamp - b.timestamp);
const mainPost = chain[0];
let { pid: mainPid, tid, uid: authorId, timestamp, name, content, _activitypub } = mainPost;
const hasTid = !!tid;
const cid = hasTid ? await topics.getTopicField(tid, 'cid') : options.cid || -1;
if (options.cid && cid === -1) {
// Move topic if currently uncategorized
await topics.tools.move(tid, { cid: options.cid, uid: 'system' });
}
const members = await db.isSortedSetMembers(`tid:${tid}:posts`, chain.slice(1).map(p => p.pid));
members.unshift(await posts.exists(mainPid));
if (tid && members.every(Boolean)) {
// All cached, return early.
// winston.verbose('[notes/assert] No new notes to process.');
unlock(id);
return { tid, count: 0 };
}
let title;
if (hasTid) {
mainPid = await topics.getTopicField(tid, 'mainPid');
} else {
// Check recipients/audience for local category
const set = activitypub.helpers.makeSet(_activitypub, ['to', 'cc', 'audience']);
const resolved = await Promise.all(Array.from(set).map(async id => await activitypub.helpers.resolveLocalId(id)));
const recipientCids = resolved
.filter(Boolean)
.filter(({ type }) => type === 'category')
.map(obj => obj.id);
if (recipientCids.length) {
// Overrides passed-in value, respect addressing from main post over booster
options.cid = recipientCids.shift();
}
// mainPid ok to leave as-is
title = name || activitypub.helpers.generateTitle(utils.decodeHTMLEntities(content));
}
mainPid = utils.isNumber(mainPid) ? parseInt(mainPid, 10) : mainPid;
// Relation & privilege check for local categories
const inputIndex = chain.map(n => n.pid).indexOf(id);
const hasRelation =
uid || hasTid ||
options.skipChecks || options.cid ||
await assertRelation(chain[inputIndex || 0]);
const privilege = `topics:${tid ? 'reply' : 'create'}`;
const allowed = await privileges.categories.can(privilege, cid, activitypub._constants.uid);
if (!hasRelation || !allowed) {
if (!hasRelation) {
winston.info(`[activitypub/notes.assert] Not asserting ${id} as it has no relation to existing tracked content.`);
}
unlock(id);
return null;
}
tid = tid || utils.generateUUID();
mainPost.tid = tid;
const urlMap = chain.reduce((map, post) => (post.url ? map.set(post.url, post.id) : map), new Map());
const unprocessed = chain.map((post) => {
post.tid = tid; // add tid to post hash
// Ensure toPids in replies are ids
if (urlMap.has(post.toPid)) {
post.toPid = urlMap.get(post.toPid);
}
return post;
}).filter((p, idx) => !members[idx]);
const count = unprocessed.length;
// winston.verbose(`[notes/assert] ${count} new note(s) found.`);
let tags;
if (!hasTid) {
const { to, cc, attachment } = mainPost._activitypub;
const systemTags = (meta.config.systemTags || '').split(',');
const maxTags = await categories.getCategoryField(cid, 'maxTags');
tags = (mainPost._activitypub.tag || [])
.filter(o => o.type === 'Hashtag' && !systemTags.includes(o.name.slice(1)))
.map(o => o.name.slice(1));
if (tags.length > maxTags) {
tags.length = maxTags;
}
await Promise.all([
topics.post({
tid,
uid: authorId,
cid,
pid: mainPid,
title,
timestamp,
tags,
content: mainPost.content,
_activitypub: mainPost._activitypub,
}),
Notes.updateLocalRecipients(mainPid, { to, cc }),
posts.attachments.update(mainPid, attachment),
]);
unprocessed.shift();
}
for (const post of unprocessed) {
const { to, cc, attachment } = post._activitypub;
// eslint-disable-next-line no-await-in-loop
await topics.reply(post);
// eslint-disable-next-line no-await-in-loop
await Promise.all([
Notes.updateLocalRecipients(post.pid, { to, cc }),
posts.attachments.update(post.pid, attachment),
]);
}
await Promise.all([
Notes.syncUserInboxes(tid, uid),
unlock(id),
]);
return { tid, count };
};
async function assertRelation(post) {
/**
* Given a mocked post object, ensures that it is related to some other object in database
* This check ensures that random content isn't added to the database just because it is received.
*/
// Is followed by at least one local user
const { followers } = await activitypub.actors.getLocalFollowCounts(post.uid);
// Local user is mentioned
const { tag } = post._activitypub;
let uids = [];
if (tag && tag.length) {
const slugs = tag.reduce((slugs, tag) => {
if (tag.type === 'Mention') {
const [slug, hostname] = tag.name.slice(1).split('@');
if (hostname === nconf.get('url_parsed').hostname) {
slugs.push(slug);
}
}
return slugs;
}, []);
uids = slugs.length ? await db.sortedSetScores('userslug:uid', slugs) : [];
uids = uids.filter(Boolean);
}
return followers > 0 || uids.length;
}
Notes.updateLocalRecipients = async (id, { to, cc }) => {
const recipients = new Set([...(to || []), ...(cc || [])]);
const uids = new Set();
await Promise.all(Array.from(recipients).map(async (recipient) => {
const { type, id } = await activitypub.helpers.resolveLocalId(recipient);
if (type === 'user' && await user.exists(id)) {
uids.add(parseInt(id, 10));
return;
}
const followedUid = await db.getObjectField('followersUrl:uid', recipient);
if (followedUid) {
const { uids: followers } = await activitypub.actors.getLocalFollowers(followedUid);
if (followers.size > 0) {
followers.forEach((uid) => {
uids.add(uid);
});
}
}
}));
if (uids.size > 0) {
await db.setAdd(`post:${id}:recipients`, Array.from(uids));
}
};
Notes.getParentChain = async (uid, input) => {
// Traverse upwards via `inReplyTo` until you find the root-level Note
const id = activitypub.helpers.isUri(input) ? input : input.id;
const chain = new Set();
const traverse = async (uid, id) => {
// Handle remote reference to local post
const { type, id: localId } = await activitypub.helpers.resolveLocalId(id);
if (type === 'post' && localId) {
return await traverse(uid, localId);
}
const postData = await posts.getPostData(id);
if (postData) {
chain.add(postData);
if (postData.toPid) {
await traverse(uid, postData.toPid);
} else if (utils.isNumber(id)) { // local pid without toPid, could be OP or reply to OP
const mainPid = await topics.getTopicField(postData.tid, 'mainPid');
if (mainPid !== parseInt(id, 10)) {
await traverse(uid, mainPid);
}
}
} else {
let object = !activitypub.helpers.isUri(input) && input.id === id ? input : undefined;
try {
object = object || await activitypub.get('uid', uid, id);
// Handle incorrect id passed in
if (id !== object.id) {
return await traverse(uid, object.id);
}
object = await activitypub.mocks.post(object);
if (object) {
chain.add(object);
if (object.toPid) {
await traverse(uid, object.toPid);
}
}
} catch (e) {
winston.verbose(`[activitypub/notes/getParentChain] Cannot retrieve ${id}, terminating here.`);
}
}
};
await traverse(uid, id);
return chain;
};
Notes.syncUserInboxes = async function (tid, uid) {
const [pids, { cid, mainPid }] = await Promise.all([
db.getSortedSetMembers(`tid:${tid}:posts`),
topics.getTopicFields(tid, ['tid', 'cid', 'mainPid']),
]);
pids.unshift(mainPid);
const recipients = await db.getSetsMembers(pids.map(id => `post:${id}:recipients`));
const uids = recipients.reduce((set, uids) => new Set([...set, ...uids.map(u => parseInt(u, 10))]), new Set());
if (uid) {
uids.add(parseInt(uid, 10));
}
const keys = Array.from(uids).map(uid => `uid:${uid}:inbox`);
const score = await db.sortedSetScore(`cid:${cid}:tids`, tid);
const removeKeys = (await db.getSetMembers(`tid:${tid}:recipients`))
.filter(uid => !uids.has(parseInt(uid, 10)))
.map((uid => `uid:${uid}:inbox`));
// winston.verbose(`[activitypub/syncUserInboxes] Syncing tid ${tid} with ${uids.size} inboxes`);
await Promise.all([
db.sortedSetsRemove(removeKeys, tid),
db.sortedSetsAdd(keys, keys.map(() => score || Date.now()), tid),
db.setAdd(`tid:${tid}:recipients`, Array.from(uids)),
]);
};
Notes.getCategoryFollowers = async (cid) => {
// Retrieves remote users who have followed a category; used to build recipient list
let uids = await db.getSortedSetRangeByScore(`cid:${cid}:uid:watch:state`, 0, -1, categories.watchStates.tracking, categories.watchStates.tracking);
uids = uids.filter(uid => !utils.isNumber(uid));
return uids;
};
Notes.announce = {};
Notes.announce.list = async ({ pid, tid }) => {
let pids = [];
if (pid) {
pids = [pid];
} else if (tid) {
let mainPid;
([pids, mainPid] = await Promise.all([
db.getSortedSetMembers(`tid:${tid}:posts`),
topics.getTopicField(tid, 'mainPid'),
]));
pids.unshift(mainPid);
}
if (!pids.length) {
return [];
}
const keys = pids.map(pid => `pid:${pid}:announces`);
let announces = await db.getSortedSetsMembersWithScores(keys);
announces = announces.reduce((memo, cur, idx) => {
if (cur.length) {
const pid = pids[idx];
cur.forEach(({ value: actor, score: timestamp }) => {
memo.push({ pid, actor, timestamp });
});
}
return memo;
}, []);
return announces;
};
Notes.announce.add = async (pid, actor, timestamp = Date.now()) => {
await db.sortedSetAdd(`pid:${pid}:announces`, timestamp, actor);
await posts.setPostField(pid, 'announces', await db.sortedSetCard(`pid:${pid}:announces`));
};
Notes.announce.remove = async (pid, actor) => {
await db.sortedSetRemove(`pid:${pid}:announces`, actor);
const count = await db.sortedSetCard(`pid:${pid}:announces`);
if (count > 0) {
await posts.setPostField(pid, 'announces', count);
} else {
await db.deleteObjectField(`post:${pid}`, 'announces');
}
};
Notes.announce.removeAll = async (pid) => {
await Promise.all([
db.delete(`pid:${pid}:announces`),
db.deleteObjectField(`post:${pid}`, 'announces'),
]);
};
Notes.delete = async (pids) => {
if (!Array.isArray(pids)) {
pids = [pids];
}
const exists = await posts.exists(pids);
pids = pids.filter((_, idx) => exists[idx]);
let tids = await posts.getPostsFields(pids, ['tid']);
tids = new Set(tids.map(obj => obj.tid));
const recipientSets = pids.map(id => `post:${id}:recipients`);
const announcerSets = pids.map(id => `pid:${id}:announces`);
await db.deleteAll([...recipientSets, ...announcerSets]);
await Promise.all(Array.from(tids).map(async tid => Notes.syncUserInboxes(tid)));
};
Notes.prune = async () => {
/**
* Prune topics in cid -1 that have received no engagement.
* Engagement is defined as:
* - Replied to (contains a local reply)
* - Post within is liked
*/
winston.info('[notes/prune] Starting scheduled pruning of topics');
const start = '-inf';
const stop = Date.now() - (1000 * 60 * 60 * 24 * 30); // 30 days; todo: make configurable?
let tids = await db.getSortedSetRangeByScore('cid:-1:tids', 0, -1, start, stop);
winston.info(`[notes/prune] Found ${tids.length} topics older than 30 days (since last activity).`);
const posters = await db.getSortedSetsMembers(tids.map(tid => `tid:${tid}:posters`));
const hasLocalVoter = await Promise.all(tids.map(async (tid) => {
const mainPid = await db.getObjectField(`topic:${tid}`, 'mainPid');
const pids = await db.getSortedSetMembers(`tid:${tid}:posts`);
pids.unshift(mainPid);
// Check voters of each pid for a local uid
const voters = new Set();
await Promise.all(pids.map(async (pid) => {
const [upvoters, downvoters] = await db.getSetsMembers([`pid:${pid}:upvote`, `pid:${pid}:downvote`]);
upvoters.forEach(uid => voters.add(uid));
downvoters.forEach(uid => voters.add(uid));
}));
return Array.from(voters).some(uid => utils.isNumber(uid));
}));
tids = tids.filter((_, idx) => {
const localPoster = posters[idx].some(uid => utils.isNumber(uid));
const localVoter = hasLocalVoter[idx];
return !localPoster && !localVoter;
});
winston.info(`[notes/prune] ${tids.length} topics eligible for pruning`);
await batch.processArray(tids, async (tids) => {
await Promise.all(tids.map(async tid => await topics.purgePostsAndTopic(tid, 0)));
}, { batch: 100 });
winston.info('[notes/prune] Scheduled pruning of topics complete.');
};

View File

@@ -90,6 +90,8 @@ Analytics.increment = function (keys, callback) {
}
};
Analytics.peek = () => local;
Analytics.getKeys = async () => db.getSortedSetRange('analyticsKeys', 0, -1);
Analytics.pageView = async function (payload) {

408
src/api/activitypub.js Normal file
View File

@@ -0,0 +1,408 @@
'use strict';
/**
* DEVELOPMENT NOTE
*
* THIS FILE IS UNDER ACTIVE DEVELOPMENT AND IS EXPLICITLY EXCLUDED FROM IMMUTABILITY GUARANTEES
*
* If you use api methods in this file, be prepared that they may be removed or modified with no warning.
*/
const nconf = require('nconf');
const winston = require('winston');
const db = require('../database');
const user = require('../user');
const meta = require('../meta');
const privileges = require('../privileges');
const activitypub = require('../activitypub');
const posts = require('../posts');
const topics = require('../topics');
const utils = require('../utils');
const activitypubApi = module.exports;
function enabledCheck(next) {
return async function (caller, params) {
if (meta.config.activitypubEnabled) {
try {
await next(caller, params);
} catch (e) {
winston.error(`[activitypub/api] Error\n${e.stack}`);
}
}
};
}
activitypubApi.follow = enabledCheck(async (caller, { type, id, actor } = {}) => {
// Privilege checks should be done upstream
const assertion = await activitypub.actors.assert(actor);
if (!assertion) {
throw new Error('[[error:activitypub.invalid-id]]');
}
actor = actor.includes('@') ? await user.getUidByUserslug(actor) : actor;
const handle = await user.getUserField(actor, 'username');
const timestamp = Date.now();
await activitypub.send(type, id, [actor], {
id: `${nconf.get('url')}/${type}/${id}#activity/follow/${handle}/${timestamp}`,
type: 'Follow',
object: actor,
});
await db.sortedSetAdd(`followRequests:${type}.${id}`, timestamp, actor);
});
// should be .undo.follow
activitypubApi.unfollow = enabledCheck(async (caller, { type, id, actor }) => {
const assertion = await activitypub.actors.assert(actor);
if (!assertion) {
throw new Error('[[error:activitypub.invalid-id]]');
}
actor = actor.includes('@') ? await user.getUidByUserslug(actor) : actor;
const handle = await user.getUserField(actor, 'username');
const timestamps = await db.sortedSetsScore([
`followRequests:${type}.${id}`,
type === 'uid' ? `followingRemote:${id}` : `cid:${id}:following`,
], actor);
const timestamp = timestamps[0] || timestamps[1];
const object = {
id: `${nconf.get('url')}/${type}/${id}#activity/follow/${handle}/${timestamp}`,
type: 'Follow',
object: actor,
};
if (type === 'uid') {
object.actor = `${nconf.get('url')}/uid/${id}`;
} else if (type === 'cid') {
object.actor = `${nconf.get('url')}/category/${id}`;
}
await activitypub.send(type, id, [actor], {
id: `${nconf.get('url')}/${type}/${id}#activity/undo:follow/${handle}/${timestamp}`,
type: 'Undo',
object,
});
if (type === 'uid') {
await Promise.all([
db.sortedSetRemove(`followingRemote:${id}`, actor),
db.decrObjectField(`user:${id}`, 'followingRemoteCount'),
]);
} else if (type === 'cid') {
await Promise.all([
db.sortedSetRemove(`cid:${id}:following`, actor),
db.sortedSetRemove(`followRequests:cid.${id}`, actor),
db.sortedSetRemove(`followersRemote:${actor}`, `cid|${id}`),
]);
}
});
activitypubApi.create = {};
async function buildRecipients(object, { pid, uid }) {
/**
* - Builds a list of targets for activitypub.send to consume
* - Extends to and cc since the activity can be addressed more widely
* - `pid` is optional, but if included, includes announcers and all authors up the toPid chain
*/
const followers = await db.getSortedSetMembers(`followersRemote:${uid}`);
let { to, cc } = object;
to = new Set(to);
cc = new Set(cc);
const followersUrl = `${nconf.get('url')}/uid/${uid}/followers`;
if (!to.has(followersUrl)) {
cc.add(followersUrl);
}
const targets = new Set([...followers, ...to, ...cc]);
// Remove any ids that aren't asserted actors
const exists = await db.isSortedSetMembers('usersRemote:lastCrawled', [...targets]);
Array.from(targets).forEach((uri, idx) => {
if (!exists[idx]) {
targets.delete(uri);
}
});
// Topic posters, post announcers and their followers
if (pid) {
const tid = await posts.getPostField(pid, 'tid');
const participants = (await db.getSortedSetMembers(`tid:${tid}:posters`))
.filter(uid => !utils.isNumber(uid)); // remote users only
const announcers = (await activitypub.notes.announce.list({ pid })).map(({ actor }) => actor);
const auxiliaries = Array.from(new Set([...participants, ...announcers]));
const auxiliaryFollowers = (await user.getUsersFields(auxiliaries, ['followersUrl']))
.filter(o => o.hasOwnProperty('followersUrl'))
.map(({ followersUrl }) => followersUrl);
[...auxiliaries].forEach(uri => targets.add(uri));
[...auxiliaries, ...auxiliaryFollowers].forEach(uri => cc.add(uri));
}
return {
to: [...to],
cc: [...cc],
targets,
};
}
activitypubApi.create.note = enabledCheck(async (caller, { pid, post }) => {
if (!post) {
post = (await posts.getPostSummaryByPids([pid], caller.uid, { stripTags: false })).pop();
if (!post) {
return;
}
} else {
pid = post.pid;
}
const allowed = await privileges.posts.can('topics:read', pid, activitypub._constants.uid);
if (!allowed) {
// winston.verbose(`[activitypub/api] Not federating creation of pid ${pid} to the fediverse due to privileges.`);
return;
}
const object = await activitypub.mocks.note(post);
const { to, cc, targets } = await buildRecipients(object, { pid, uid: post.user.uid });
const { cid } = post.category;
const followers = await activitypub.notes.getCategoryFollowers(cid);
const payload = {
id: `${object.id}#activity/create/${Date.now()}`,
type: 'Create',
to,
cc,
object,
};
await activitypub.send('uid', caller.uid, Array.from(targets), payload);
if (followers.length) {
setTimeout(() => { // Delay sending to avoid potential race condition
Promise.all([payload, payload.object].map(async (object) => {
await activitypub.send('cid', cid, followers, {
id: `${nconf.get('url')}/post/${encodeURIComponent(object.object ? object.object.id : object.id)}#activity/announce/${Date.now()}`,
type: 'Announce',
to: [activitypub._constants.publicAddress],
cc: [`${nconf.get('url')}/category/${cid}/followers`],
object,
});
})).catch(err => winston.error(err.stack));
}, 5000);
}
});
activitypubApi.update = {};
activitypubApi.update.profile = enabledCheck(async (caller, { uid }) => {
const [object, targets] = await Promise.all([
activitypub.mocks.actors.user(uid),
db.getSortedSetMembers(`followersRemote:${caller.uid}`),
]);
await activitypub.send('uid', caller.uid, targets, {
id: `${object.id}#activity/update/${Date.now()}`,
type: 'Update',
to: [activitypub._constants.publicAddress],
cc: [],
object,
});
});
activitypubApi.update.category = enabledCheck(async (caller, { cid }) => {
const [object, targets] = await Promise.all([
activitypub.mocks.actors.category(cid),
activitypub.notes.getCategoryFollowers(cid),
]);
await activitypub.send('cid', cid, targets, {
id: `${object.id}#activity/update/${Date.now()}`,
type: 'Update',
to: [activitypub._constants.publicAddress],
cc: [],
object,
});
});
activitypubApi.update.note = enabledCheck(async (caller, { post }) => {
// Only applies to local posts
if (!utils.isNumber(post.pid)) {
return;
}
const object = await activitypub.mocks.note(post);
const { to, cc, targets } = await buildRecipients(object, { pid: post.pid, uid: post.user.uid });
const allowed = await privileges.posts.can('topics:read', post.pid, activitypub._constants.uid);
if (!allowed) {
// winston.verbose(
// `[activitypub/api] Not federating update of pid ${post.pid} to the fediverse due to privileges.`
// );
return;
}
const payload = {
id: `${object.id}#activity/update/${post.edited || Date.now()}`,
type: 'Update',
to,
cc,
object,
};
await activitypub.send('uid', caller.uid, Array.from(targets), payload);
});
activitypubApi.delete = {};
activitypubApi.delete.note = enabledCheck(async (caller, { pid }) => {
// Only applies to local posts
if (!utils.isNumber(pid)) {
return;
}
const id = `${nconf.get('url')}/post/${pid}`;
const post = (await posts.getPostSummaryByPids([pid], caller.uid, { stripTags: false })).pop();
const object = await activitypub.mocks.note(post);
const { to, cc, targets } = await buildRecipients(object, { pid, uid: post.user.uid });
const allowed = await privileges.posts.can('topics:read', pid, activitypub._constants.uid);
if (!allowed) {
// winston.verbose(`[activitypub/api] Not federating update of pid ${pid} to the fediverse due to privileges.`);
return;
}
const payload = {
id: `${id}#activity/delete/${Date.now()}`,
type: 'Delete',
to,
cc,
object: id,
origin: object.context,
};
await activitypub.send('uid', caller.uid, Array.from(targets), payload);
});
activitypubApi.like = {};
activitypubApi.like.note = enabledCheck(async (caller, { pid }) => {
if (!activitypub.helpers.isUri(pid)) { // remote only
return;
}
const uid = await posts.getPostField(pid, 'uid');
if (!activitypub.helpers.isUri(uid)) {
return;
}
await activitypub.send('uid', caller.uid, [uid], {
id: `${nconf.get('url')}/uid/${caller.uid}#activity/like/${encodeURIComponent(pid)}`,
type: 'Like',
object: pid,
});
});
activitypubApi.announce = {};
activitypubApi.announce.note = enabledCheck(async (caller, { tid }) => {
const { mainPid: pid, cid } = await topics.getTopicFields(tid, ['mainPid', 'cid']);
// Only remote posts can be announced to real categories
if (utils.isNumber(pid) || parseInt(cid, 10) === -1) {
return;
}
const uid = await posts.getPostField(pid, 'uid'); // author
const allowed = await privileges.posts.can('topics:read', pid, activitypub._constants.uid);
if (!allowed) {
// winston.verbose(`[activitypub/api] Not federating announce of pid ${pid} to the fediverse due to privileges.`);
return;
}
const { to, cc, targets } = await buildRecipients({
id: pid,
to: [activitypub._constants.publicAddress],
cc: [`${nconf.get('url')}/uid/${caller.uid}/followers`, uid],
}, { uid: caller.uid });
await activitypub.send('uid', caller.uid, Array.from(targets), {
id: `${nconf.get('url')}/post/${encodeURIComponent(pid)}#activity/announce/${Date.now()}`,
type: 'Announce',
to,
cc,
object: pid,
target: `${nconf.get('url')}/category/${cid}`,
});
});
activitypubApi.undo = {};
// activitypubApi.undo.follow =
activitypubApi.undo.like = enabledCheck(async (caller, { pid }) => {
if (!activitypub.helpers.isUri(pid)) {
return;
}
const uid = await posts.getPostField(pid, 'uid');
if (!activitypub.helpers.isUri(uid)) {
return;
}
await activitypub.send('uid', caller.uid, [uid], {
id: `${nconf.get('url')}/uid/${caller.uid}#activity/undo:like/${encodeURIComponent(pid)}/${Date.now()}`,
type: 'Undo',
object: {
actor: `${nconf.get('url')}/uid/${caller.uid}`,
id: `${nconf.get('url')}/uid/${caller.uid}#activity/like/${encodeURIComponent(pid)}`,
type: 'Like',
object: pid,
},
});
});
activitypubApi.flag = enabledCheck(async (caller, flag) => {
if (!activitypub.helpers.isUri(flag.targetId)) {
return;
}
const reportedIds = [flag.targetId];
if (flag.type === 'post' && activitypub.helpers.isUri(flag.targetUid)) {
reportedIds.push(flag.targetUid);
}
const reason = flag.reason ||
(flag.reports && flag.reports.filter(report => report.reporter.uid === caller.uid).at(-1).value);
await activitypub.send('uid', caller.uid, reportedIds, {
id: `${nconf.get('url')}/${flag.type}/${encodeURIComponent(flag.targetId)}#activity/flag/${caller.uid}`,
type: 'Flag',
object: reportedIds,
content: reason,
});
await db.sortedSetAdd(`flag:${flag.flagId}:remote`, Date.now(), caller.uid);
});
activitypubApi.undo.flag = enabledCheck(async (caller, flag) => {
if (!activitypub.helpers.isUri(flag.targetId)) {
return;
}
const reportedIds = [flag.targetId];
if (flag.type === 'post' && activitypub.helpers.isUri(flag.targetUid)) {
reportedIds.push(flag.targetUid);
}
const reason = flag.reason ||
(flag.reports && flag.reports.filter(report => report.reporter.uid === caller.uid).at(-1).value);
await activitypub.send('uid', caller.uid, reportedIds, {
id: `${nconf.get('url')}/${flag.type}/${encodeURIComponent(flag.targetId)}#activity/undo:flag/${caller.uid}/${Date.now()}`,
type: 'Undo',
object: {
id: `${nconf.get('url')}/${flag.type}/${encodeURIComponent(flag.targetId)}#activity/flag/${caller.uid}`,
actor: `${nconf.get('url')}/uid/${caller.uid}`,
type: 'Flag',
object: reportedIds,
content: reason,
},
});
await db.sortedSetRemove(`flag:${flag.flagId}:remote`, caller.uid);
});

View File

@@ -8,6 +8,8 @@ const user = require('../user');
const groups = require('../groups');
const privileges = require('../privileges');
const activitypubApi = require('./activitypub');
const categoriesAPI = module.exports;
const hasAdminPrivilege = async (uid, privilege = 'categories') => {
@@ -63,6 +65,7 @@ categoriesAPI.update = async function (caller, data) {
const payload = {};
payload[cid] = values;
await categories.update(payload);
activitypubApi.update.category(caller, { cid }); // background
};
categoriesAPI.delete = async function (caller, { cid }) {

View File

@@ -11,7 +11,7 @@ flagsApi.create = async (caller, data) => {
throw new Error('[[error:invalid-data]]');
}
const { type, id, reason } = data;
const { type, id, reason, notifyRemote } = data;
await flags.validate({
uid: caller.uid,
@@ -19,7 +19,7 @@ flagsApi.create = async (caller, data) => {
id: id,
});
const flagObj = await flags.create(type, id, caller.uid, reason);
const flagObj = await flags.create(type, id, caller.uid, reason, undefined, undefined, notifyRemote);
flags.notify(flagObj, caller.uid);
return flagObj;
@@ -59,6 +59,24 @@ flagsApi.rescind = async ({ uid }, { flagId }) => {
await flags.rescindReport(type, targetId, uid);
};
flagsApi.rescindPost = async ({ uid }, { pid }) => {
const exists = await flags.exists('post', pid, uid);
if (!exists) {
throw new Error('[[error:no-flag]]');
}
await flags.rescindReport('post', pid, uid);
};
flagsApi.rescindUser = async ({ uid }, { uid: targetUid }) => {
const exists = await flags.exists('user', targetUid, uid);
if (!exists) {
throw new Error('[[error:no-flag]]');
}
await flags.rescindReport('user', targetUid, uid);
};
flagsApi.appendNote = async (caller, data) => {
const allowed = await user.isPrivileged(caller.uid);
if (!allowed) {

View File

@@ -129,6 +129,7 @@ exports.postCommand = async function (caller, command, eventName, notification,
};
async function executeCommand(caller, command, eventName, notification, data) {
const api = require('.');
const result = await posts[command](data.pid, caller.uid);
if (result && eventName) {
websockets.in(`uid_${caller.uid}`).emit(`posts.${command}`, result);
@@ -136,10 +137,12 @@ async function executeCommand(caller, command, eventName, notification, data) {
}
if (result && command === 'upvote') {
socketHelpers.upvote(result, notification);
api.activitypub.like.note(caller, { pid: data.pid });
} else if (result && notification) {
socketHelpers.sendNotificationToPostOwner(data.pid, caller.uid, command, notification);
} else if (result && command === 'unvote') {
socketHelpers.rescindUpvoteNotification(data.pid, caller.uid);
api.activitypub.undo.like(caller, { pid: data.pid });
}
return result;
}

View File

@@ -11,6 +11,7 @@ module.exports = {
categories: require('./categories'),
search: require('./search'),
flags: require('./flags'),
activitypub: require('./activitypub'),
files: require('./files'),
utils: require('./utils'),
};

View File

@@ -7,13 +7,13 @@ const db = require('../database');
const utils = require('../utils');
const user = require('../user');
const posts = require('../posts');
const postsCache = require('../posts/cache');
const topics = require('../topics');
const groups = require('../groups');
const plugins = require('../plugins');
const meta = require('../meta');
const events = require('../events');
const privileges = require('../privileges');
const activitypub = require('../activitypub');
const apiHelpers = require('./helpers');
const websockets = require('../socket.io');
const socketHelpers = require('../socket.io/helpers');
@@ -90,17 +90,25 @@ postsAPI.edit = async function (caller, data) {
if (!caller.uid) {
throw new Error('[[error:not-logged-in]]');
}
// Trim and remove HTML (latter for composers that send in HTML, like redactor)
const contentLen = utils.stripHTMLTags(data.content).trim().length;
// Discard content for non-local posts
if (!utils.isNumber(data.pid)) {
data.content = null;
} else {
// Trim and remove HTML (latter for composers that send in HTML, like redactor)
const contentLen = utils.stripHTMLTags(data.content).trim().length;
if (meta.config.minimumPostLength !== 0 && contentLen < meta.config.minimumPostLength) {
throw new Error(`[[error:content-too-short, ${meta.config.minimumPostLength}]]`);
} else if (contentLen > meta.config.maximumPostLength) {
throw new Error(`[[error:content-too-long, ${meta.config.maximumPostLength}]]`);
}
}
if (data.title && data.title.length < meta.config.minimumTitleLength) {
throw new Error(`[[error:title-too-short, ${meta.config.minimumTitleLength}]]`);
} else if (data.title && data.title.length > meta.config.maximumTitleLength) {
throw new Error(`[[error:title-too-long, ${meta.config.maximumTitleLength}]]`);
} else if (meta.config.minimumPostLength !== 0 && contentLen < meta.config.minimumPostLength) {
throw new Error(`[[error:content-too-short, ${meta.config.minimumPostLength}]]`);
} else if (contentLen > meta.config.maximumPostLength) {
throw new Error(`[[error:content-too-long, ${meta.config.maximumPostLength}]]`);
} else if (!await posts.canUserPostContentWithLinks(caller.uid, data.content)) {
throw new Error(`[[error:not-enough-reputation-to-post-links, ${meta.config['min:rep:post-links']}]]`);
}
@@ -135,12 +143,17 @@ postsAPI.edit = async function (caller, data) {
newTitle: validator.escape(String(editResult.topic.title)),
});
}
const postObj = await posts.getPostSummaryByPids([editResult.post.pid], caller.uid, {});
const postObj = await posts.getPostSummaryByPids([editResult.post.pid], caller.uid, { parse: false, extraFields: ['edited'] });
postObj.content = editResult.post.content; // re-use already parsed html
const returnData = { ...postObj[0], ...editResult.post };
returnData.topic = { ...postObj[0].topic, ...editResult.post.topic };
if (!editResult.post.deleted) {
websockets.in(`topic_${editResult.topic.tid}`).emit('event:post_edited', editResult);
setTimeout(() => {
require('.').activitypub.update.note(caller, { post: postObj[0] });
}, 5000);
return returnData;
}
@@ -153,6 +166,7 @@ postsAPI.edit = async function (caller, data) {
const uids = _.uniq(_.flatten(memberData).concat(String(caller.uid)));
uids.forEach(uid => websockets.in(`uid_${uid}`).emit('event:post_edited', editResult));
return returnData;
};
@@ -191,6 +205,11 @@ async function deleteOrRestore(caller, data, params) {
tid: postData.tid,
ip: caller.ip,
});
// Explicitly non-awaited
posts.getPostSummaryByPids([data.pid], caller.uid, {}).then(([post]) => {
require('.').activitypub.update.note(caller, { post });
});
}
async function deleteOrRestoreTopicOf(command, pid, caller) {
@@ -209,16 +228,22 @@ async function deleteOrRestoreTopicOf(command, pid, caller) {
}
postsAPI.purge = async function (caller, data) {
if (!data || !parseInt(data.pid, 10)) {
if (!data || !data.pid) {
throw new Error('[[error:invalid-data]]');
}
const results = await isMainAndLastPost(data.pid);
if (results.isMain && !results.isLast) {
const [exists, { isMain, isLast }] = await Promise.all([
posts.exists(data.pid),
isMainAndLastPost(data.pid),
]);
if (!exists) {
throw new Error('[[error:no-post]]');
}
if (isMain && !isLast) {
throw new Error('[[error:cant-purge-main-post]]');
}
const isMainAndLast = results.isMain && results.isLast;
const isMainAndLast = isMain && isLast;
const postData = await posts.getPostFields(data.pid, ['toPid', 'tid']);
postData.pid = data.pid;
@@ -226,8 +251,11 @@ postsAPI.purge = async function (caller, data) {
if (!canPurge) {
throw new Error('[[error:no-privileges]]');
}
postsCache.del(data.pid);
await posts.purge(data.pid, caller.uid);
posts.clearCachedPost(data.pid);
await Promise.all([
posts.purge(data.pid, caller.uid),
require('.').activitypub.delete.note(caller, { pid: data.pid }),
]);
websockets.in(`topic_${postData.tid}`).emit('event:post_purged', postData);
const topicData = await topics.getTopicFields(postData.tid, ['title', 'cid']);
@@ -355,9 +383,13 @@ postsAPI.getUpvoters = async function (caller, data) {
throw new Error('[[error:no-privileges]]');
}
let upvotedUids = (await posts.getUpvotedUidsByPids([pid]))[0];
const upvotedUids = (await posts.getUpvotedUidsByPids([pid]))[0];
return await getTooltipData(upvotedUids);
};
async function getTooltipData(uids) {
const cutoff = 6;
if (!upvotedUids.length) {
if (!uids.length) {
return {
otherCount: 0,
usernames: [],
@@ -365,17 +397,41 @@ postsAPI.getUpvoters = async function (caller, data) {
};
}
let otherCount = 0;
if (upvotedUids.length > cutoff) {
otherCount = upvotedUids.length - (cutoff - 1);
upvotedUids = upvotedUids.slice(0, cutoff - 1);
if (uids.length > cutoff) {
otherCount = uids.length - (cutoff - 1);
uids = uids.slice(0, cutoff - 1);
}
const usernames = await user.getUsernamesByUids(upvotedUids);
const usernames = await user.getUsernamesByUids(uids);
return {
otherCount,
usernames,
cutoff,
};
}
postsAPI.getAnnouncers = async (caller, data) => {
if (!data.pid) {
throw new Error('[[error:invalid-data]]');
}
if (!meta.config.activitypubEnabled) {
return [];
}
const { pid } = data;
const cid = await posts.getCidByPid(pid);
if (!await privileges.categories.isUserAllowedTo('topics:read', cid, caller.uid)) {
throw new Error('[[error:no-privileges]]');
}
const notes = require('../activitypub/notes');
const announcers = await notes.announce.list({ pid });
const uids = announcers.map(ann => ann.actor);
if (data.tooltip) {
return await getTooltipData(uids);
}
return {
announceCount: uids.length,
announcers: await user.getUsersFields(uids, ['username', 'userslug', 'picture']),
};
};
async function canSeeVotes(uid, cids, type) {
@@ -489,7 +545,7 @@ postsAPI.deleteDiff = async (caller, { pid, timestamp }) => {
};
postsAPI.getReplies = async (caller, { pid }) => {
if (!utils.isNumber(pid)) {
if (!utils.isNumber(pid) && !activitypub.helpers.isUri(pid)) {
throw new Error('[[error:invalid-data]]');
}
const { uid } = caller;

View File

@@ -29,6 +29,9 @@ searchApi.categories = async (caller, data) => {
({ cids, matchedCids } = await findMatchedCids(caller.uid, data));
} else {
cids = await loadCids(caller.uid, data.parentCid);
if (meta.config.activitypubEnabled) {
cids.unshift(-1);
}
}
const visibleCategories = await controllersHelpers.getVisibleCategories({

View File

@@ -11,6 +11,7 @@ const privileges = require('../privileges');
const events = require('../events');
const batch = require('../batch');
const activitypubApi = require('./activitypub');
const apiHelpers = require('./helpers');
const { doTopicAction } = apiHelpers;
@@ -83,6 +84,12 @@ topicsAPI.create = async function (caller, data) {
socketHelpers.emitToUids('event:new_topic', result.topicData, [caller.uid]);
socketHelpers.notifyNew(caller.uid, 'newTopic', { posts: [result.postData], topic: result.topicData });
if (!isScheduling) {
setTimeout(() => {
activitypubApi.create.note(caller, { pid: result.postData.pid });
}, 5000);
}
return result.topicData;
};
@@ -100,7 +107,6 @@ topicsAPI.reply = async function (caller, data) {
}
const postData = await topics.reply(payload); // postData seems to be a subset of postObj, refactor?
const postObj = await posts.getPostSummaryByPids([postData.pid], caller.uid, {});
const result = {
posts: [postData],
@@ -116,8 +122,9 @@ topicsAPI.reply = async function (caller, data) {
}
socketHelpers.notifyNew(caller.uid, 'newPost', result);
activitypubApi.create.note(caller, { post: postData });
return postObj[0];
return postData;
};
topicsAPI.delete = async function (caller, data) {
@@ -331,6 +338,7 @@ topicsAPI.move = async (caller, { tid, cid }) => {
socketHelpers.emitToUids('event:topic_moved', topicData, notifyUids);
if (!topicData.deleted) {
socketHelpers.sendNotificationToTopicOwner(tid, caller.uid, 'move', 'notifications:moved-your-topic');
activitypubApi.announce.note(caller, { tid });
}
await events.log({

View File

@@ -175,27 +175,11 @@ usersAPI.changePassword = async function (caller, data) {
usersAPI.follow = async function (caller, data) {
await user.follow(caller.uid, data.uid);
await user.onFollow(caller.uid, data.uid);
plugins.hooks.fire('action:user.follow', {
fromUid: caller.uid,
toUid: data.uid,
});
const userData = await user.getUserFields(caller.uid, ['username', 'userslug']);
const { displayname } = userData;
const notifObj = await notifications.create({
type: 'follow',
bodyShort: `[[notifications:user-started-following-you, ${displayname}]]`,
nid: `follow:${data.uid}:uid:${caller.uid}`,
from: caller.uid,
path: `/uid/${caller.uid}`,
mergeId: 'notifications:user-started-following-you',
});
if (!notifObj) {
return;
}
notifObj.user = userData;
await notifications.push(notifObj, [data.uid]);
};
usersAPI.unfollow = async function (caller, data) {
@@ -531,7 +515,7 @@ async function isPrivilegedOrSelfAndPasswordMatch(caller, data) {
async function processDeletion({ uid, method, password, caller }) {
const isTargetAdmin = await user.isAdministrator(uid);
const isSelf = parseInt(uid, 10) === parseInt(caller.uid, 10);
const isSelf = String(uid) === String(caller.uid);
const hasAdminPrivilege = await privileges.admin.can('admin:users', caller.uid);
if (isSelf && meta.config.allowAccountDelete !== 1) {

8
src/cache/lru.js vendored
View File

@@ -57,6 +57,14 @@ module.exports = function (opts) {
});
});
cache.has = function (key) {
if (!cache.enabled) {
return false;
}
return lruCache.has(key);
};
cache.set = function (key, value, ttl) {
if (!cache.enabled) {
return;

2
src/cache/ttl.js vendored
View File

@@ -30,7 +30,7 @@ module.exports = function (opts) {
});
});
cache.has = (key) => {
cache.has = function (key) {
if (!cache.enabled) {
return false;
}

View File

@@ -5,6 +5,7 @@ const _ = require('lodash');
const db = require('../database');
const plugins = require('../plugins');
const meta = require('../meta');
const privileges = require('../privileges');
const utils = require('../utils');
const slugify = require('../slugify');
@@ -20,6 +21,7 @@ module.exports = function (Categories) {
data.name = String(data.name || `Category ${cid}`);
const slug = `${cid}/${slugify(data.name)}`;
const handle = await Categories.generateHandle(slugify(data.name));
const smallestOrder = firstChild.length ? firstChild[0].score - 1 : 1;
const order = data.order || smallestOrder; // If no order provided, place it at the top
const colours = Categories.assignColours();
@@ -27,6 +29,7 @@ module.exports = function (Categories) {
let category = {
cid: cid,
name: data.name,
handle,
description: data.description ? data.description : '',
descriptionParsed: data.descriptionParsed ? data.descriptionParsed : '',
icon: data.icon ? data.icon : '',
@@ -91,7 +94,7 @@ module.exports = function (Categories) {
['categories:name', 0, `${data.name.slice(0, 200).toLowerCase()}:${category.cid}`],
]);
await privileges.categories.give(result.defaultPrivileges, category.cid, 'registered-users');
await privileges.categories.give(result.defaultPrivileges, category.cid, ['registered-users', 'fediverse']);
await privileges.categories.give(result.modPrivileges, category.cid, ['administrators', 'Global Moderators']);
await privileges.categories.give(result.guestPrivileges, category.cid, ['guests', 'spiders']);
@@ -146,6 +149,19 @@ module.exports = function (Categories) {
await async.each(children, Categories.create);
}
async function generateHandle(slug) {
let taken = await meta.slugTaken(slug);
let suffix;
while (taken) {
suffix = utils.generateUUID().slice(0, 8);
// eslint-disable-next-line no-await-in-loop
taken = await meta.slugTaken(`${slug}-${suffix}`);
}
return `${slug}${suffix ? `-${suffix}` : ''}`;
}
Categories.generateHandle = generateHandle; // exported for upgrade script (4.0.0)
Categories.assignColours = function () {
const backgrounds = ['#AB4642', '#DC9656', '#F7CA88', '#A1B56C', '#86C1B9', '#7CAFC2', '#BA8BAF', '#A16946'];
const text = ['#ffffff', '#ffffff', '#333333', '#ffffff', '#333333', '#ffffff', '#ffffff', '#ffffff'];

View File

@@ -13,14 +13,45 @@ const intFields = [
'minTags', 'maxTags', 'postQueue', 'subCategoriesPerPage',
];
const worldCategory = {
cid: -1,
name: 'Uncategorized',
description: 'Topics that do not strictly fit in with any existing categories',
icon: 'fa-globe',
imageClass: 'cover',
bgColor: '#eee',
color: '#333',
slug: '../world',
parentCid: 0,
disabled: 0,
handle: 'world',
link: '',
class: '', // todo
};
worldCategory.descriptionParsed = worldCategory.description;
module.exports = function (Categories) {
Categories.getCategoriesFields = async function (cids, fields) {
if (!Array.isArray(cids) || !cids.length) {
return [];
}
cids = cids.map(cid => parseInt(cid, 10));
const keys = cids.map(cid => `category:${cid}`);
const categories = await db.getObjects(keys, fields);
// Handle cid -1
if (cids.includes(-1)) {
let subset = null;
if (fields && fields.length) {
subset = fields.reduce((category, field) => {
category[field] = worldCategory[field] || undefined;
return category;
}, {});
}
categories.splice(cids.indexOf(-1), 1, subset || { ...worldCategory });
}
const result = await plugins.hooks.fire('filter:category.getFields', {
cids: cids,
categories: categories,

View File

@@ -30,6 +30,13 @@ Categories.exists = async function (cids) {
);
};
Categories.existsByHandle = async function (handle) {
if (Array.isArray(handle)) {
return await db.isSortedSetMembers('categoryhandle:cid', handle);
}
return await db.isSortedSetMember('categoryhandle:cid', handle);
};
Categories.getCategoryById = async function (data) {
const categories = await Categories.getCategories([data.cid]);
if (!categories[0]) {
@@ -39,7 +46,7 @@ Categories.getCategoryById = async function (data) {
data.category = category;
const promises = [
Categories.getCategoryTopics(data),
data.cid !== '-1' ? Categories.getCategoryTopics(data) : [],
Categories.getTopicCount(data),
Categories.getWatchState([data.cid], data.uid),
getChildrenTree(category, data.uid),
@@ -67,6 +74,10 @@ Categories.getCategoryById = async function (data) {
return { ...result.category };
};
Categories.getCidByHandle = async function (handle) {
return await db.sortedSetScore('categoryhandle:cid', handle);
};
Categories.getAllCidsFromSet = async function (key) {
let cids = cache.get(key);
if (cids) {
@@ -86,6 +97,10 @@ Categories.getAllCategories = async function () {
Categories.getCidsByPrivilege = async function (set, uid, privilege) {
const cids = await Categories.getAllCidsFromSet(set);
if (set === 'categories:cid') {
cids.unshift(-1);
}
return await privileges.categories.filterCids(privilege, cids, uid);
};

View File

@@ -49,6 +49,8 @@ module.exports = function (Categories) {
return await updateTagWhitelist(cid, value);
} else if (key === 'name') {
return await updateName(cid, value);
} else if (key === 'handle') {
return await updateHandle(cid, value);
} else if (key === 'order') {
return await updateOrder(cid, value);
}
@@ -142,4 +144,22 @@ module.exports = function (Categories) {
await db.sortedSetAdd('categories:name', 0, `${newName.slice(0, 200).toLowerCase()}:${cid}`);
await db.setObjectField(`category:${cid}`, 'name', newName);
}
async function updateHandle(cid, handle) {
const existing = await Categories.getCategoryField(cid, 'handle');
if (existing === handle) {
return;
}
const taken = await meta.slugTaken(handle);
if (taken) {
throw new Error('[[error:category.handle-taken]]');
}
await Promise.all([
db.setObjectField(`category:${cid}`, 'handle', handle),
db.sortedSetRemove('categoryhandle:cid', existing),
db.sortedSetAdd('categoryhandle:cid', cid, handle),
]);
}
};

View File

@@ -2,6 +2,7 @@
const db = require('../database');
const user = require('../user');
const activitypub = require('../activitypub');
module.exports = function (Categories) {
Categories.watchStates = {
@@ -20,7 +21,7 @@ module.exports = function (Categories) {
};
Categories.getWatchState = async function (cids, uid) {
if (!(parseInt(uid, 10) > 0)) {
if (!activitypub.helpers.isUri(uid) && !(parseInt(uid, 10) > 0)) {
return cids.map(() => Categories.watchStates.notwatching);
}
if (!Array.isArray(cids) || !cids.length) {

View File

@@ -31,7 +31,7 @@ async function getFollow(tpl, name, req, res, next) {
payload.title = `[[pages:${tpl}, ${username}]]`;
const method = name === 'following' ? 'getFollowing' : 'getFollowers';
payload.users = await user[method](res.locals.uid, start, stop);
payload.users = await user[method](res.locals.userData.uid, start, stop);
const count = name === 'following' ? followingCount : followerCount;
const pageCount = Math.ceil(count / resultsPerPage);

View File

@@ -13,6 +13,9 @@ const privileges = require('../../privileges');
const translator = require('../../translator');
const messaging = require('../../messaging');
const categories = require('../../categories');
const posts = require('../../posts');
const activitypub = require('../../activitypub');
const flags = require('../../flags');
const relative_path = nconf.get('relative_path');
@@ -24,7 +27,12 @@ helpers.getUserDataByUserSlug = async function (userslug, callerUID, query = {})
return null;
}
const results = await getAllData(uid, callerUID);
const [results, canFlag, flagged, flagId] = await Promise.all([
getAllData(uid, callerUID),
privileges.users.canFlag(callerUID, uid),
flags.exists('user', uid, callerUID),
flags.getFlagIdByTarget('user', uid),
]);
if (!results.userData) {
throw new Error('[[error:invalid-uid]]');
}
@@ -74,7 +82,8 @@ helpers.getUserDataByUserSlug = async function (userslug, callerUID, query = {})
userData.canEdit = results.canEdit;
userData.canBan = results.canBanUser;
userData.canMute = results.canMuteUser;
userData.canFlag = (await privileges.users.canFlag(callerUID, userData.uid)).flag;
userData.canFlag = canFlag.flag;
userData.flagId = flagged ? flagId : null;
userData.canChangePassword = isAdmin || (isSelf && !meta.config['password:disableEdit']);
userData.isSelf = isSelf;
userData.isFollowing = results.isFollowing;
@@ -181,6 +190,7 @@ async function canChat(callerUID, uid) {
async function getCounts(userData, callerUID) {
const { uid } = userData;
const isRemote = activitypub.helpers.isUri(uid);
const cids = await categories.getCidsByPrivilege('categories:cid', callerUID, 'topics:read');
const promises = {
posts: db.sortedSetsCardSum(cids.map(c => `cid:${c}:uid:${uid}:pids`)),
@@ -200,6 +210,7 @@ async function getCounts(userData, callerUID) {
promises.blocks = user.getUserField(userData.uid, 'blocksCount');
}
const counts = await utils.promiseParallel(promises);
counts.posts = isRemote ? userData.postcount : counts.posts;
counts.categoriesWatched = counts.categoriesWatched && counts.categoriesWatched.length;
counts.groups = userData.groups.length;
counts.following = userData.followingCount;
@@ -273,7 +284,12 @@ async function parseAboutMe(userData) {
userData.aboutme = '';
userData.aboutmeParsed = '';
return;
} else if (activitypub.helpers.isUri(userData.uid)) {
userData.aboutme = posts.sanitize(userData.aboutme);
userData.aboutmeParsed = userData.aboutme;
return;
}
userData.aboutme = validator.escape(String(userData.aboutme || ''));
const parsed = await plugins.hooks.fire('filter:parse.aboutme', userData.aboutme);
userData.aboutme = translator.escape(userData.aboutme);

View File

@@ -177,20 +177,27 @@ async function getPostsFromUserSet(template, req, res) {
const data = templateToData[template];
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
let { uid } = res.locals;
if (uid === -2) {
uid = await db.getObjectField('handle:uid', req.params.userslug.toLowerCase());
}
const payload = res.locals.userData;
const { username, userslug } = payload;
const { username, userslug } = uid === -2 ?
await user.getUserFields(uid, ['username', 'userslug']) :
payload;
const settings = await user.getSettings(req.uid);
const itemsPerPage = data.type === 'topics' ? settings.topicsPerPage : settings.postsPerPage;
const start = (page - 1) * itemsPerPage;
const stop = start + itemsPerPage - 1;
const sets = await data.getSets(req.uid, { uid: res.locals.uid, username, userslug });
const sets = await data.getSets(req.uid, { uid, username, userslug });
let result;
if (plugins.hooks.hasListeners('filter:account.getPostsFromUserSet')) {
result = await plugins.hooks.fire('filter:account.getPostsFromUserSet', {
req: req,
template: template,
userData: { uid: res.locals.uid, username, userslug },
userData: { uid, username, userslug },
settings: settings,
data: data,
start: start,

View File

@@ -1,8 +1,10 @@
'use strict';
const _ = require('lodash');
const nconf = require('nconf');
const db = require('../../database');
const meta = require('../../meta');
const user = require('../../user');
const posts = require('../../posts');
const categories = require('../../categories');
@@ -41,7 +43,12 @@ profileController.get = async function (req, res, next) {
userData.profileviews = 1;
}
addMetaTags(res, userData);
addTags(res, userData);
if (meta.config.activitypubEnabled) {
// Include link header for richer parsing
res.set('Link', `<${nconf.get('url')}/uid/${userData.uid}>; rel="alternate"; type="application/activity+json"`);
}
res.render('account/profile', userData);
};
@@ -112,7 +119,7 @@ async function getPosts(callerUid, userData, setSuffix) {
return postData.slice(0, count);
}
function addMetaTags(res, userData) {
function addTags(res, userData) {
const plainAboutMe = userData.aboutme ? utils.stripHTMLTags(utils.decodeHTMLEntities(userData.aboutme)) : '';
res.locals.metaTags = [
{
@@ -149,4 +156,12 @@ function addMetaTags(res, userData) {
}
);
}
if (meta.config.activitypubEnabled) {
res.locals.linkTags = [{
rel: 'alternate',
type: 'application/activity+json',
href: `${nconf.get('url')}/uid/${userData.uid}`,
}];
}
}

View File

@@ -0,0 +1,174 @@
'use strict';
const nconf = require('nconf');
const winston = require('winston');
const db = require('../../database');
const meta = require('../../meta');
const privileges = require('../../privileges');
const posts = require('../../posts');
const topics = require('../../topics');
const categories = require('../../categories');
const activitypub = require('../../activitypub');
const utils = require('../../utils');
const Actors = module.exports;
Actors.application = async function (req, res) {
const publicKey = await activitypub.getPublicKey('uid', 0);
const name = meta.config.title || 'NodeBB';
res.status(200).json({
'@context': [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
],
id: `${nconf.get('url')}/actor`,
url: `${nconf.get('url')}/actor`,
inbox: `${nconf.get('url')}/inbox`,
outbox: `${nconf.get('url')}/outbox`,
type: 'Application',
name,
preferredUsername: nconf.get('url_parsed').hostname,
publicKey: {
id: `${nconf.get('url')}/actor#key`,
owner: `${nconf.get('url')}/actor`,
publicKeyPem: publicKey,
},
});
};
Actors.user = async function (req, res) {
// todo: view:users priv gate
const payload = await activitypub.mocks.actors.user(req.params.uid);
res.status(200).json(payload);
};
Actors.userBySlug = async function (req, res) {
const { uid } = res.locals;
req.params.uid = uid;
delete req.params.userslug;
Actors.user(req, res);
};
Actors.note = async function (req, res) {
// technically a note isn't an actor, but it is here purely for organizational purposes.
// but also, wouldn't it be wild if you could follow a note? lol.
const allowed = utils.isNumber(req.params.pid) && await privileges.posts.can('topics:read', req.params.pid, activitypub._constants.uid);
const post = (await posts.getPostSummaryByPids([req.params.pid], req.uid, { stripTags: false })).pop();
if (!allowed || !post) {
return res.sendStatus(404);
}
const payload = await activitypub.mocks.note(post);
res.status(200).json(payload);
};
Actors.replies = async function (req, res) {
const allowed = utils.isNumber(req.params.pid) && await privileges.posts.can('topics:read', req.params.pid, activitypub._constants.uid);
const exists = await posts.exists(req.params.pid);
if (!allowed || !exists) {
return res.sendStatus(404);
}
const page = parseInt(req.query.page, 10);
const replies = await activitypub.helpers.generateCollection({
set: `pid:${req.params.pid}:replies`,
page,
perPage: meta.config.postsPerPage,
url: `${nconf.get('url')}/post/${req.params.pid}/replies`,
});
// Convert pids to urls
replies.orderedItems = replies.orderedItems.map(pid => (utils.isNumber(pid) ? `${nconf.get('url')}/post/${pid}` : pid));
const object = {
'@context': 'https://www.w3.org/ns/activitystreams',
id: `${nconf.get('url')}/post/${req.params.pid}/replies${replies.orderedItems && page ? `?page=${page}` : ''}`,
url: `${nconf.get('url')}/post/${req.params.pid}`,
...replies,
};
res.status(200).json(object);
};
Actors.topic = async function (req, res, next) {
const allowed = await privileges.topics.can('topics:read', req.params.tid, activitypub._constants.uid);
if (!allowed) {
return res.sendStatus(404);
}
const page = parseInt(req.query.page, 10);
const perPage = meta.config.postsPerPage;
const { cid, titleRaw: name, mainPid, slug } = await topics.getTopicFields(req.params.tid, ['cid', 'title', 'mainPid', 'slug']);
try {
let [collection, pids] = await Promise.all([
activitypub.helpers.generateCollection({
set: `tid:${req.params.tid}:posts`,
method: posts.getPidsFromSet,
page,
perPage,
url: `${nconf.get('url')}/topic/${req.params.tid}`,
}),
db.getSortedSetMembers(`tid:${req.params.tid}:posts`),
]);
// Generate digest for ETag
pids.push(mainPid);
pids = pids.map(pid => (utils.isNumber(pid) ? `${nconf.get('url')}/post/${pid}` : pid));
const digest = activitypub.helpers.generateDigest(new Set(pids));
const ifNoneMatch = (req.get('If-None-Match') || '').split(',').map((tag) => {
tag = tag.trim();
if (tag.startsWith('"') && tag.endsWith('"')) {
return tag.slice(1, tag.length - 1);
}
return tag;
});
if (ifNoneMatch.includes(digest)) {
return res.sendStatus(304);
}
res.set('ETag', digest);
// Convert pids to urls
collection.totalItems += 1;
if (page || collection.totalItems < meta.config.postsPerPage) {
collection.orderedItems = collection.orderedItems || [];
if (!page || page === 1) { // add OP to collection
collection.orderedItems.unshift(mainPid);
}
collection.orderedItems = collection.orderedItems.map(pid => (utils.isNumber(pid) ? `${nconf.get('url')}/post/${pid}` : pid));
}
const object = {
'@context': 'https://www.w3.org/ns/activitystreams',
id: `${nconf.get('url')}/topic/${req.params.tid}${collection.orderedItems && page ? `?page=${page}` : ''}`,
url: `${nconf.get('url')}/topic/${slug}`,
name,
attributedTo: `${nconf.get('url')}/category/${cid}`,
audience: cid !== -1 ? `${nconf.get('url')}/category/${cid}` : undefined,
...collection,
};
res.status(200).json(object);
} catch (e) {
winston.error(`[activitypub/actors.topic] Unable to generate topic actor: ${e.message}`);
return next();
}
};
Actors.category = async function (req, res, next) {
const [exists, allowed] = await Promise.all([
categories.exists(req.params.cid),
privileges.categories.can('find', req.params.cid, activitypub._constants.uid),
]);
if (!exists || !allowed) {
return next('route');
}
const payload = await activitypub.mocks.actors.category(req.params.cid);
res.status(200).json(payload);
};

View 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);
}
};

View File

@@ -0,0 +1,101 @@
'use strict';
const nconf = require('nconf');
const db = require('../../database');
const user = require('../../user');
const topics = require('../../topics');
const pagination = require('../../pagination');
const helpers = require('../helpers');
const categories = require('../../categories');
const privileges = require('../../privileges');
const translator = require('../../translator');
const meta = require('../../meta');
const controller = module.exports;
const validSorts = [
'recently_replied', 'recently_created', 'most_posts', 'most_votes', 'most_views',
];
controller.list = async function (req, res) {
if (!req.uid) {
return helpers.redirect(res, '/recent?cid=-1', false);
}
const { topicsPerPage } = await user.getSettings(req.uid);
const page = parseInt(req.query.page, 10) || 1;
const start = Math.max(0, (page - 1) * topicsPerPage);
const stop = start + topicsPerPage - 1;
const sortToSet = {
recently_replied: `cid:-1:tids`,
recently_created: `cid:-1:tids:create`,
most_posts: `cid:-1:tids:posts`,
most_votes: `cid:-1:tids:votes`,
most_views: `cid:-1:tids:views`,
};
const [userPrivileges, tagData, userSettings, rssToken] = await Promise.all([
privileges.categories.get('-1', req.uid),
helpers.getSelectedTag(req.query.tag),
user.getSettings(req.uid),
user.auth.getFeedToken(req.uid),
]);
const sort = validSorts.includes(req.query.sort) ? req.query.sort : userSettings.categoryTopicSort;
let tids = await db.getSortedSetRevRange(sortToSet[sort], 0, 499);
const isMembers = await db.isSortedSetMembers(`uid:${req.uid}:inbox`, tids);
tids = tids.filter((tid, idx) => isMembers[idx]);
const count = tids.length;
tids = tids.slice(start, stop + 1);
const targetUid = await user.getUidByUserslug(req.query.author);
const data = await categories.getCategoryById({
uid: req.uid,
cid: '-1',
start: start,
stop: stop,
sort: sort,
settings: userSettings,
query: req.query,
tag: req.query.tag,
targetUid: targetUid,
});
data.name = '[[activitypub:world.name]]';
delete data.children;
data.topicCount = count;
data.topics = await topics.getTopicsByTids(tids, { uid: req.uid });
topics.calculateTopicIndices(data.topics, start);
data.title = translator.escape(data.name);
data.privileges = userPrivileges;
data.selectedTag = tagData.selectedTag;
data.selectedTags = tagData.selectedTags;
data.breadcrumbs = helpers.buildBreadcrumbs([{ text: `[[pages:world]]` }]);
data['feeds:disableRSS'] = meta.config['feeds:disableRSS'] || 0;
data['reputation:disabled'] = meta.config['reputation:disabled'];
if (!meta.config['feeds:disableRSS']) {
data.rssFeedUrl = `${nconf.get('url')}/category/${data.cid}.rss`;
if (req.loggedIn) {
data.rssFeedUrl += `?uid=${req.uid}&token=${rssToken}`;
}
}
const pageCount = Math.max(1, Math.ceil(data.topicCount / topicsPerPage));
data.pagination = pagination.create(page, pageCount, req.query);
helpers.addLinkTags({
url: 'world',
res: req.res,
tags: data.pagination.rel,
page: page,
});
res.render('world', data);
};

View File

@@ -2,11 +2,14 @@
const _ = require('lodash');
const nconf = require('nconf');
const db = require('../../database');
const user = require('../../user');
const categories = require('../../categories');
const analytics = require('../../analytics');
const plugins = require('../../plugins');
const translator = require('../../translator');
const meta = require('../../meta');
const activitypub = require('../../activitypub');
const helpers = require('../helpers');
const pagination = require('../../pagination');
@@ -145,3 +148,31 @@ categoriesController.getAnalytics = async function (req, res) {
selectedCategory: selectedData.selectedCategory,
});
};
categoriesController.getFederation = async function (req, res) {
const cid = req.params.category_id;
let [_following, pending, followers, name, { selectedCategory }] = await Promise.all([
db.getSortedSetMembers(`cid:${cid}:following`),
db.getSortedSetMembers(`followRequests:cid.${cid}`),
activitypub.notes.getCategoryFollowers(cid),
categories.getCategoryField(cid, 'name'),
helpers.getSelectedCategory(cid),
]);
const following = [..._following, ...pending].map(entry => ({
id: entry,
approved: !pending.includes(entry),
}));
await activitypub.actors.assert(followers);
followers = await user.getUsersFields(followers, ['userslug', 'picture']);
res.render('admin/manage/category-federation', {
cid: cid,
enabled: meta.config.activitypubEnabled,
name,
selectedCategory,
following,
followers,
});
};

View File

@@ -2,6 +2,7 @@
const categories = require('../../categories');
const privileges = require('../../privileges');
const utils = require('../../utils');
const privilegesController = module.exports;
@@ -10,10 +11,10 @@ privilegesController.get = async function (req, res) {
const isAdminPriv = req.params.cid === 'admin';
let privilegesData;
if (cid > 0) {
privilegesData = await privileges.categories.list(cid);
} else if (cid === 0) {
if (cid === 0) {
privilegesData = await (isAdminPriv ? privileges.admin.list(req.uid) : privileges.global.list());
} else if (utils.isNumber(cid)) {
privilegesData = await privileges.categories.list(cid);
}
const categoriesData = [{

View File

@@ -9,6 +9,7 @@ const groups = require('../../groups');
const languages = require('../../languages');
const navigationAdmin = require('../../navigation/admin');
const social = require('../../social');
const activitypub = require('../../activitypub');
const api = require('../../api');
const pagination = require('../../pagination');
const helpers = require('../helpers');
@@ -123,3 +124,12 @@ settingsController.api = async (req, res) => {
pagination: pagination.create(page, pageCount, req.query),
});
};
settingsController.activitypub = async (req, res) => {
const instanceCount = await activitypub.instances.getCount();
res.render('admin/settings/activitypub', {
title: `[[admin/menu:settings/activitypub]]`,
instanceCount,
});
};

Some files were not shown because too many files have changed in this diff Show More