diff --git a/install/data/defaults.json b/install/data/defaults.json index 076a1435d050..f5e1e0d94e1e 100644 --- a/install/data/defaults.json +++ b/install/data/defaults.json @@ -189,5 +189,7 @@ "composer:allowPluginHelp": 1, "maxReconnectionAttempts": 5, "reconnectionDelay": 1500, - "disableCustomUserSkins": 0 + "disableCustomUserSkins": 0, + "activitypubEnabled": 1, + "activitypubAllowLoopback": 0 } diff --git a/install/data/navigation.json b/install/data/navigation.json index 5a744dbdf76c..0532dae54579 100644 --- a/install/data/navigation.json +++ b/install/data/navigation.json @@ -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]]", diff --git a/install/package.json b/install/package.json index 2d284b1e419f..92c5ee3c3a1c 100644 --- a/install/package.json +++ b/install/package.json @@ -46,6 +46,7 @@ "bootswatch": "5.3.3", "chalk": "4.1.2", "chart.js": "4.4.3", + "cheerio": "^1.0.0-rc.12", "cli-graph": "3.2.2", "clipboard": "2.0.11", "colors": "1.4.0", @@ -98,12 +99,12 @@ "nodebb-plugin-dbsearch": "6.2.3", "nodebb-plugin-emoji": "5.1.15", "nodebb-plugin-emoji-android": "4.0.0", - "nodebb-plugin-markdown": "12.2.7", - "nodebb-plugin-mentions": "4.4.3", + "nodebb-plugin-markdown": "13.0.0-pre.4", + "nodebb-plugin-mentions": "4.6.0", "nodebb-plugin-ntfy": "1.7.4", "nodebb-plugin-spam-be-gone": "2.2.2", "nodebb-rewards-essentials": "1.0.0", - "nodebb-theme-harmony": "1.2.57", + "nodebb-theme-harmony": "2.0.0-pre.23", "nodebb-theme-lavender": "7.1.8", "nodebb-theme-peace": "2.2.5", "nodebb-theme-persona": "13.3.20", @@ -195,4 +196,4 @@ "url": "https://github.com/barisusakli" } ] -} \ No newline at end of file +} diff --git a/public/language/en-GB/activitypub.json b/public/language/en-GB/activitypub.json new file mode 100644 index 000000000000..54fa13b6e3cb --- /dev/null +++ b/public/language/en-GB/activitypub.json @@ -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 you 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 you, 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!", + + "topic-event-announce-ago": "%1 shared this post %3", + "topic-event-announce-on": "%1 shared this post on %3" +} \ No newline at end of file diff --git a/public/language/en-GB/admin/manage/categories.json b/public/language/en-GB/admin/manage/categories.json index 8a9ff471b030..77e39a2d0d55 100644 --- a/public/language/en-GB/admin/manage/categories.json +++ b/public/language/en-GB/admin/manage/categories.json @@ -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,18 @@ "analytics.topics-daily": "Figure 3 – Daily topics created in this category", "analytics.posts-daily": "Figure 4 – Daily posts made in this category", + "federation.title": "Federation settings for \"%1\" category", + "federation.disabled": "Federation is disabled site-wide, so category federation settings are currently unavailable.", + "federation.disabled-cta": "Federation Settings →", + "federation.syncing-header": "Synchronization", + "federation.syncing-intro": "A category can follow a \"Group Actor\" via the ActivityPub protocol. If content is received from one of the actors listed below, it will be automatically added to this category.", + "federation.syncing-caveat": "N.B. Setting up syncing here establishes a one-way synchronization. NodeBB attempts to subscribe/follow the actor, but the reverse cannot be assumed.", + "federation.syncing-none": "This category is not currently following anybody.", + "federation.syncing-add": "Synchronize with...", + "federation.syncing-actorUri": "Actor", + "federation.syncing-follow": "Follow", + "federation.syncing-unfollow": "Unfollow", + "alert.created": "Created", "alert.create-success": "Category successfully created!", "alert.none-active": "You have no active categories.", diff --git a/public/language/en-GB/admin/menu.json b/public/language/en-GB/admin/menu.json index 6e30be22b381..913c74f475a2 100644 --- a/public/language/en-GB/admin/menu.json +++ b/public/language/en-GB/admin/menu.json @@ -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", diff --git a/public/language/en-GB/admin/settings/activitypub.json b/public/language/en-GB/admin/settings/activitypub.json new file mode 100644 index 000000000000..73661cd7785c --- /dev/null +++ b/public/language/en-GB/admin/settings/activitypub.json @@ -0,0 +1,14 @@ +{ + "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 ActivityPub. If enabled, NodeBB will also be able to communicate with other apps and websites that use ActivityPub (e.g. Mastodon, Peertube, etc.)", + "general": "General", + "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 %1 server(s)", + "server.filter-help": "Specify servers you would like to bar from federating with your NodeBB. Alternatively, you may opt to selectively allow federation with specific servers, instead. Both options are supported, although they are mutually exclusive.", + "server.filter-allow-list": "Use this as an Allow List instead" +} \ No newline at end of file diff --git a/public/language/en-GB/error.json b/public/language/en-GB/error.json index d849187baec7..fba2f0eaef04 100644 --- a/public/language/en-GB/error.json +++ b/public/language/en-GB/error.json @@ -264,6 +264,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", @@ -277,5 +278,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" } diff --git a/public/language/en-GB/flags.json b/public/language/en-GB/flags.json index 0a42e3877f05..7985a69f3323 100644 --- a/public/language/en-GB/flags.json +++ b/public/language/en-GB/flags.json @@ -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 ", "auto-flagged": "[Auto Flagged] Received %1 downvotes." diff --git a/public/language/en-GB/global.json b/public/language/en-GB/global.json index 005196398d75..c947bb92ae97 100644 --- a/public/language/en-GB/global.json +++ b/public/language/en-GB/global.json @@ -56,6 +56,7 @@ "header.navigation": "Navigation", "header.manage": "Manage", "header.drafts": "Drafts", + "header.world": "World", "notifications.loading": "Loading Notifications", "chats.loading": "Loading Chats", diff --git a/public/language/en-GB/notifications.json b/public/language/en-GB/notifications.json index 2782fdaff9da..fa54f162edac 100644 --- a/public/language/en-GB/notifications.json +++ b/public/language/en-GB/notifications.json @@ -106,5 +106,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": "%1 shared your post in %2 to their followers.", + "activitypub.announce-dual": "%1 and %2 shared your post in %3 to their followers.", + "activitypub.announce-triple": "%1, %2 and %3 shared your post in %4 to their followers.", + "activitypub.announce-multiple": "%1, %2 and %3 others shared your post in %4 to their followers." } diff --git a/public/language/en-GB/pages.json b/public/language/en-GB/pages.json index 81cf369c5ec1..6020c5662480 100644 --- a/public/language/en-GB/pages.json +++ b/public/language/en-GB/pages.json @@ -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\"", diff --git a/public/language/en-GB/topic.json b/public/language/en-GB/topic.json index 90277746035b..f970d116542d 100644 --- a/public/language/en-GB/topic.json +++ b/public/language/en-GB/topic.json @@ -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", diff --git a/public/language/en-GB/user.json b/public/language/en-GB/user.json index b01089832269..e2bb9eeb4f6c 100644 --- a/public/language/en-GB/user.json +++ b/public/language/en-GB/user.json @@ -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", diff --git a/public/openapi/components/schemas/CategoryObject.yaml b/public/openapi/components/schemas/CategoryObject.yaml index 4d6cb0ca4e03..0b138542b04a 100644 --- a/public/openapi/components/schemas/CategoryObject.yaml +++ b/public/openapi/components/schemas/CategoryObject.yaml @@ -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) diff --git a/public/openapi/components/schemas/PostObject.yaml b/public/openapi/components/schemas/PostObject.yaml index ea91579cc67f..914ca408374b 100644 --- a/public/openapi/components/schemas/PostObject.yaml +++ b/public/openapi/components/schemas/PostObject.yaml @@ -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: diff --git a/public/openapi/components/schemas/UserObject.yaml b/public/openapi/components/schemas/UserObject.yaml index 9b217cee8b20..4bbf83abc3c9 100644 --- a/public/openapi/components/schemas/UserObject.yaml +++ b/public/openapi/components/schemas/UserObject.yaml @@ -443,6 +443,9 @@ UserObjectFull: type: boolean canFlag: type: boolean + flagId: + type: number + nullable: true canChangePassword: type: boolean isSelf: diff --git a/public/openapi/read.yaml b/public/openapi/read.yaml index 1e1b721d6fad..3597fb1be876 100644 --- a/public/openapi/read.yaml +++ b/public/openapi/read.yaml @@ -100,6 +100,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: @@ -324,5 +326,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' \ No newline at end of file diff --git a/public/openapi/read/admin/manage/categories/category_id/federation.yaml b/public/openapi/read/admin/manage/categories/category_id/federation.yaml new file mode 100644 index 000000000000..27f9464c3afd --- /dev/null +++ b/public/openapi/read/admin/manage/categories/category_id/federation.yaml @@ -0,0 +1,41 @@ +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 + selectedCategory: + $ref: ../../../../../components/schemas/CategoryObject.yaml#/CategoryObject + - $ref: ../../../../../components/schemas/CommonProps.yaml#/CommonProps \ No newline at end of file diff --git a/public/openapi/read/world.yaml b/public/openapi/read/world.yaml new file mode 100644 index 000000000000..2b29fbc24785 --- /dev/null +++ b/public/openapi/read/world.yaml @@ -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 \ No newline at end of file diff --git a/public/openapi/write.yaml b/public/openapi/write.yaml index 79657b519e4b..286583352b5d 100644 --- a/public/openapi/write.yaml +++ b/public/openapi/write.yaml @@ -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}: diff --git a/public/openapi/write/categories/cid/follow.yaml b/public/openapi/write/categories/cid/follow.yaml new file mode 100644 index 000000000000..cd8f8a0b960e --- /dev/null +++ b/public/openapi/write/categories/cid/follow.yaml @@ -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: 'foobar@example.org' + 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: 'foobar@example.org' + responses: + '200': + description: successfully unsynchronized category + content: + application/json: + schema: + type: object + properties: + status: + $ref: ../../../components/schemas/Status.yaml#/Status + response: + type: object + properties: {} \ No newline at end of file diff --git a/public/scss/admin/general/navigation.scss b/public/scss/admin/general/navigation.scss index 7233458abed5..ce33850c7b09 100644 --- a/public/scss/admin/general/navigation.scss +++ b/public/scss/admin/general/navigation.scss @@ -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; } } diff --git a/public/src/admin/manage/category-federation.js b/public/src/admin/manage/category-federation.js new file mode 100644 index 000000000000..8c646647b456 --- /dev/null +++ b/public/src/admin/manage/category-federation.js @@ -0,0 +1,48 @@ +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', + }); + + document.getElementById('site-settings').addEventListener('click', async (e) => { + const subselector = e.target.closest('[data-action]'); + if (!subselector) { + return; + } + + const action = subselector.getAttribute('data-action'); + + switch (action) { + case 'follow': { + const inputEl = document.getElementById('syncing.add'); + const actor = inputEl.value; + + put(`/categories/${ajaxify.data.cid}/follow`, { actor }) + .then(ajaxify.refresh) + .catch(error); + + break; + } + + case 'unfollow': { + const actor = subselector.getAttribute('data-actor'); + + del(`/categories/${ajaxify.data.cid}/follow`, { actor }) + .then(ajaxify.refresh) + .catch(error); + + break; + } + } + }); +} + diff --git a/public/src/admin/manage/privileges.js b/public/src/admin/manage/privileges.js index 1c4f829f3fbb..5da36cb0d343 100644 --- a/public/src/admin/manage/privileges.js +++ b/public/src/admin/manage/privileges.js @@ -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); diff --git a/public/src/client/account/header.js b/public/src/client/account/header.js index dfa888120b3a..9b34fa6fdc47 100644 --- a/public/src/client/account/header.js +++ b/public/src/client/account/header.js @@ -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, diff --git a/public/src/client/flags/list.js b/public/src/client/flags/list.js index 08e7fdb88cf6..110ef1f334da 100644 --- a/public/src/client/flags/list.js +++ b/public/src/client/flags/list.js @@ -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); }); diff --git a/public/src/client/topic.js b/public/src/client/topic.js index b4dd497dc318..48132a29faca 100644 --- a/public/src/client/topic.js +++ b/public/src/client/topic.js @@ -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 (link.parents('[component="post"]').attr('data-pid') === pid) { return; // dont render self post } diff --git a/public/src/client/topic/delete-posts.js b/public/src/client/topic/delete-posts.js index 6ce4e0f8a09d..58b4058f09d8 100644 --- a/public/src/client/topic/delete-posts.js +++ b/public/src/client/topic/delete-posts.js @@ -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)}`); }); }); }; diff --git a/public/src/client/topic/diffs.js b/public/src/client/topic/diffs.js index 22fca5868e23..6fb5c9e01881 100644 --- a/public/src/client/topic/diffs.js +++ b/public/src/client/topic/diffs.js @@ -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'); diff --git a/public/src/client/topic/events.js b/public/src/client/topic/events.js index e091dd69c899..73e1a91efc45 100644 --- a/public/src/client/topic/events.js +++ b/public/src/client/topic/events.js @@ -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); } diff --git a/public/src/client/topic/move-post.js b/public/src/client/topic/move-post.js index 555737d194bc..3c154b93749c 100644 --- a/public/src/client/topic/move-post.js +++ b/public/src/client/topic/move-post.js @@ -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) { diff --git a/public/src/client/topic/postTools.js b/public/src/client/topic/postTools.js index 571361734fab..92348d07a437 100644 --- a/public/src/client/topic/postTools.js +++ b/public/src/client/topic/postTools.js @@ -147,6 +147,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) { @@ -318,7 +330,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); }); } @@ -348,7 +360,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); } @@ -417,7 +429,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); }); } diff --git a/public/src/client/topic/posts.js b/public/src/client/topic/posts.js index c800704b795f..b88dc7360859 100644 --- a/public/src/client/topic/posts.js +++ b/public/src/client/topic/posts.js @@ -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; } diff --git a/public/src/client/topic/replies.js b/public/src/client/topic/replies.js index a70862c11972..20633935ef3a 100644 --- a/public/src/client/topic/replies.js +++ b/public/src/client/topic/replies.js @@ -14,7 +14,7 @@ define('forum/topic/replies', ['forum/topic/posts', 'hooks', 'alerts', 'api'], f if (open.is(':not(.hidden)') && loading.is('.hidden')) { open.addClass('hidden'); loading.removeClass('hidden'); - api.get(`/posts/${pid}/replies`, {}, function (err, { replies }) { + api.get(`/posts/${encodeURIComponent(pid)}/replies`, {}, function (err, { replies }) { const postData = replies; loading.addClass('hidden'); if (err) { diff --git a/public/src/client/topic/votes.js b/public/src/client/topic/votes.js index b4365697ed69..c3913650db94 100644 --- a/public/src/client/topic/votes.js +++ b/public/src/client/topic/votes.js @@ -80,7 +80,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) { diff --git a/public/src/client/world.js b/public/src/client/world.js new file mode 100644 index 000000000000..84ed411d26cb --- /dev/null +++ b/public/src/client/world.js @@ -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 = [ + '

[[activitypub:help.intro]]

', + '

[[activitypub:help.fediverse]]

', + '

[[activitypub:help.build]]

', + '

[[activitypub:help.federating]]

', + '

[[activitypub:help.next-generation]]

', + ]; + + trigger.addEventListener('click', () => { + bootbox.dialog({ + title: '[[activitypub:help.title]]', + message: content.join('\n'), + size: 'large', + }); + }); + } + + return World; +}); diff --git a/public/src/modules/api.js b/public/src/modules/api.js index fcee6b94b0c3..5cac8cf42d2d 100644 --- a/public/src/modules/api.js +++ b/public/src/modules/api.js @@ -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'); diff --git a/public/src/modules/flags.js b/public/src/modules/flags.js index bff55bf8b592..5eae76d9bb22 100644 --- a/public/src/modules/flags.js +++ b/public/src/modules/flags.js @@ -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); diff --git a/public/src/modules/helpers.common.js b/public/src/modules/helpers.common.js index 6c17927918b7..b426fdaf515a 100644 --- a/public/src/modules/helpers.common.js +++ b/public/src/modules/helpers.common.js @@ -27,6 +27,7 @@ module.exports = function (utils, Benchpress, relative_path) { increment, generateRepliedTo, generateWrote, + encodeURIComponent: _encodeURIComponent, isoTimeToLocaleString, shouldHideReplyContainer, humanReadableNumber, @@ -174,7 +175,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)) { @@ -189,15 +190,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 `
- +
`; @@ -335,13 +341,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') { diff --git a/public/src/modules/slugify.js b/public/src/modules/slugify.js index 3046ed2b94bc..159b356e4786 100644 --- a/public/src/modules/slugify.js +++ b/public/src/modules/slugify.js @@ -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; diff --git a/public/src/modules/tagFilter.js b/public/src/modules/tagFilter.js index d8a4557b70e4..f752b0df7e26 100644 --- a/public/src/modules/tagFilter.js +++ b/public/src/modules/tagFilter.js @@ -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) { diff --git a/public/src/modules/topicThumbs.js b/public/src/modules/topicThumbs.js index 21869fe1a506..5495bad541f7 100644 --- a/public/src/modules/topicThumbs.js +++ b/public/src/modules/topicThumbs.js @@ -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, diff --git a/src/activitypub/actors.js b/src/activitypub/actors.js new file mode 100644 index 000000000000..628246464e86 --- /dev/null +++ b/src/activitypub/actors.js @@ -0,0 +1,205 @@ +'use strict'; + +const winston = require('winston'); +const nconf = require('nconf'); + +const db = require('../database'); +const user = require('../user'); +const utils = require('../utils'); +const TTLCache = require('../cache/ttl'); + +const failedWebfingerCache = TTLCache({ ttl: 1000 * 60 * 10 }); // 10 minutes + +const activitypub = module.parent.exports; + +const Actors = module.exports; + +Actors.assert = async (ids, options = {}) => { + // Handle single values + if (!Array.isArray(ids)) { + ids = [ids]; + } + + // Existance in failure cache is automatic assertion failure + if (ids.some(id => failedWebfingerCache.has(id))) { + return false; + } + + // Filter out uids if passed in + ids = ids.filter(id => !utils.isNumber(id)); + + // Translate webfinger handles to uris + ids = (await Promise.all(ids.map(async (id) => { + const originalId = id; + if (activitypub.helpers.isWebfinger(id)) { + const host = id.split('@')[1]; + if (host === nconf.get('url_parsed').host) { // do not assert loopback ids + return 'loopback'; + } + + ({ actorUri: id } = await activitypub.helpers.query(id)); + } + // ensure the final id is a valid URI + if (!id || !activitypub.helpers.isUri(id)) { + failedWebfingerCache.set(originalId, true); + return; + } + return id; + }))); + + // Webfinger failures = assertion failure + if (!ids.every(Boolean)) { + return false; + } + + // Filter out loopback uris + ids = ids.filter(uri => uri !== 'loopback' && new URL(uri).host !== nconf.get('url_parsed').host); + + // Filter out existing + if (!options.update) { + const exists = await db.isSortedSetMembers('usersRemote:lastCrawled', ids.map(id => ((typeof id === 'object' && id.hasOwnProperty('id')) ? id.id : id))); + ids = ids.filter((id, idx) => !exists[idx]); + } + + if (!ids.length) { + return true; + } + + winston.verbose(`[activitypub/actors] Asserting ${ids.length} actor(s)`); + + const urlMap = new Map(); + const followersUrlMap = new Map(); + const pubKeysMap = new Map(); + let actors = await Promise.all(ids.map(async (id) => { + try { + winston.verbose(`[activitypub/actors] Processing ${id}`); + const actor = (typeof id === 'object' && id.hasOwnProperty('id')) ? id : await activitypub.get('uid', 0, id); + + // Follow counts + try { + const [followers, following] = await Promise.all([ + actor.followers ? activitypub.get('uid', 0, actor.followers) : { totalItems: 0 }, + actor.following ? activitypub.get('uid', 0, actor.following) : { totalItems: 0 }, + ]); + actor.followerCount = followers.totalItems; + actor.followingCount = following.totalItems; + } catch (e) { + // no action required + winston.verbose(`[activitypub/actor.assert] Unable to retrieve follower counts for ${actor.id}`); + } + + // Post count + try { + const outbox = actor.outbox ? await activitypub.get('uid', 0, actor.outbox) : { totalItems: 0 }; + actor.postcount = outbox.totalItems; + } catch (e) { + // no action required + winston.verbose(`[activitypub/actor.assert] Unable to retrieve post counts for ${actor.id}`); + } + + // Save url for backreference + const url = Array.isArray(actor.url) ? actor.url.shift() : actor.url; + if (url && url !== actor.id) { + urlMap.set(url, actor.id); + } + + // Save followers url for backreference + if (actor.hasOwnProperty('followers') && activitypub.helpers.isUri(actor.followers)) { + followersUrlMap.set(actor.followers, actor.id); + } + + // Public keys + pubKeysMap.set(actor.id, actor.publicKey); + + return actor; + } catch (e) { + return null; + } + })); + actors = actors.filter(Boolean); // remove unresolvable actors + + // Build userData object for storage + const profiles = await activitypub.mocks.profile(actors); + const now = Date.now(); + + const bulkSet = profiles.reduce((memo, profile) => { + const key = `userRemote:${profile.uid}`; + memo.push([key, profile], [`${key}:keys`, pubKeysMap.get(profile.uid)]); + return memo; + }, []); + if (urlMap.size) { + bulkSet.push(['remoteUrl:uid', Object.fromEntries(urlMap)]); + } + if (followersUrlMap.size) { + bulkSet.push(['followersUrl:uid', Object.fromEntries(followersUrlMap)]); + } + + const exists = await db.isSortedSetMembers('usersRemote:lastCrawled', profiles.map(p => p.uid)); + const uidsForCurrent = profiles.map((p, idx) => (exists[idx] ? p.uid : 0)); + const current = await user.getUsersFields(uidsForCurrent, ['username', 'fullname']); + const queries = profiles.reduce((memo, profile, idx) => { + const { username, fullname } = current[idx]; + + if (username !== profile.username) { + if (uidsForCurrent[idx] !== 0) { + memo.searchRemove.push(['ap.preferredUsername:sorted', `${username.toLowerCase()}:${profile.uid}`]); + memo.handleRemove.push(username.toLowerCase()); + } + + memo.searchAdd.push(['ap.preferredUsername:sorted', 0, `${profile.username.toLowerCase()}:${profile.uid}`]); + memo.handleAdd[profile.username.toLowerCase()] = profile.uid; + } + + if (profile.fullname && fullname !== profile.fullname) { + if (uidsForCurrent[idx] !== 0) { + memo.searchRemove.push(['ap.name:sorted', `${fullname.toLowerCase()}:${profile.uid}`]); + } + + memo.searchAdd.push(['ap.name:sorted', 0, `${profile.fullname.toLowerCase()}:${profile.uid}`]); + } + + return memo; + }, { searchRemove: [], searchAdd: [], handleRemove: [], handleAdd: {} }); + + await Promise.all([ + db.setObjectBulk(bulkSet), + db.sortedSetAdd('usersRemote:lastCrawled', profiles.map(() => now), profiles.map(p => p.uid)), + db.sortedSetRemoveBulk(queries.searchRemove), + db.sortedSetAddBulk(queries.searchAdd), + db.deleteObjectFields('handle:uid', queries.handleRemove), + db.setObject('handle:uid', queries.handleAdd), + ]); + + return actors; +}; + +Actors.getLocalFollowers = async (id) => { + const response = { + uids: new Set(), + cids: new Set(), + }; + + if (!activitypub.helpers.isUri(id)) { + return response; + } + + const members = await db.getSortedSetMembers(`followersRemote:${id}`); + + members.forEach((id) => { + if (utils.isNumber(id)) { + response.uids.add(parseInt(id, 10)); + } else if (id.startsWith('cid|') && utils.isNumber(id.slice(4))) { + response.cids.add(parseInt(id.slice(4), 10)); + } + }); + + return response; +}; + +Actors.getLocalFollowersCount = async (id) => { + if (!activitypub.helpers.isUri(id)) { + return false; + } + + return await db.sortedSetCard(`followersRemote:${id}`); +}; diff --git a/src/activitypub/helpers.js b/src/activitypub/helpers.js new file mode 100644 index 000000000000..c6f8e5c45156 --- /dev/null +++ b/src/activitypub/helpers.js @@ -0,0 +1,306 @@ +'use strict'; + +const { generateKeyPairSync } = require('crypto'); +const winston = require('winston'); +const nconf = require('nconf'); +const validator = require('validator'); +const cheerio = require('cheerio'); + +const meta = require('../meta'); +const posts = require('../posts'); +const categories = require('../categories'); +const request = require('../request'); +const db = require('../database'); +const ttl = require('../cache/ttl'); +const user = require('../user'); +const activitypub = require('.'); + +const webfingerRegex = /^(@|acct:)?\w+@.+$/; +const webfingerCache = ttl({ ttl: 1000 * 60 * 60 * 24 }); // 24 hours + +const Helpers = module.exports; + +Helpers.isUri = (value) => { + if (typeof value !== 'string') { + value = String(value); + } + + return validator.isURL(value, { + require_protocol: true, + require_host: true, + protocols: activitypub._constants.acceptedProtocols, + require_valid_protocol: true, + require_tld: false, // temporary — for localhost + }); +}; + +Helpers.isWebfinger = (value) => { + // N.B. returns normalized handle, so truthy check! + if (webfingerRegex.test(value) && !Helpers.isUri(value)) { + if (value.startsWith('@')) { + return value.slice(1); + } else if (value.startsWith('acct:')) { + return value.slice(5); + } + + return value; + } + + return false; +}; + +Helpers.query = async (id) => { + const isUri = Helpers.isUri(id); + // username@host ids use acct: URI schema + const uri = isUri ? new URL(id) : new URL(`acct:${id}`); + // JS doesn't parse anything other than protocol and pathname from acct: URIs, so we need to just split id manually + const [username, hostname] = isUri ? [uri.pathname || uri.href, uri.host] : id.split('@'); + if (!username || !hostname) { + return false; + } + + if (webfingerCache.has(id)) { + return webfingerCache.get(id); + } + + 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] = hash.split('/', 3); + activityData = { activity, data }; + } + + // https://bb.devnull.land/cid/2#activity/follow/activitypub@community.nodebb.org│ + switch (prefix) { + case 'uid': + return { type: 'user', id: value, ...activityData }; + + case 'post': + return { type: 'post', id: value, ...activityData }; + + case 'cid': + case 'category': + return { type: 'category', id: value, ...activityData }; + + case 'user': { + const uid = await user.getUidByUserslug(value); + return { type: 'user', id: uid, ...activityData }; + } + } + + return { type: null, id: null, ...activityData }; + } + + return { type: null, id: null }; + } else if (String(input).indexOf('@') !== -1) { // Webfinger + input = decodeURIComponent(input); + const [slug] = input.replace(/^acct:/, '').split('@'); + const uid = await user.getUidByUserslug(slug); + return { type: 'user', id: uid }; + } + + return { type: null, id: null }; +}; + +Helpers.resolveActor = (type, id) => { + switch (type) { + case 'user': + case 'uid': { + return `${nconf.get('url')}${id > 0 ? `/uid/${id}` : '/actor'}`; + } + + case 'category': + case 'cid': { + return `${nconf.get('url')}/category/${id}`; + } + + default: + throw new Error('[[error:activitypub.invalid-id]]'); + } +}; + +Helpers.resolveActivity = async (activity, data, id, resolved) => { + switch (activity.toLowerCase()) { + case 'follow': { + const actor = await Helpers.resolveActor(resolved.type, resolved.id); + const { actorUri: targetUri } = await Helpers.query(data); + return { + '@context': 'https://www.w3.org/ns/activitystreams', + actor, + id, + type: 'Follow', + object: targetUri, + }; + } + case 'announce': + case 'create': { + const object = await Helpers.resolveObjects(resolved.id); + // local create activities are assumed to come from the user who created the underlying object + const actor = object.attributedTo || object.actor; + return { + '@context': 'https://www.w3.org/ns/activitystreams', + actor, + id, + type: 'Create', + object, + }; + } + default: { + throw new Error('[[error:activitypub.not-implemented]]'); + } + } +}; + +Helpers.mapToLocalType = (type) => { + if (type === 'Person') { + return 'user'; + } + if (type === 'Group') { + return 'category'; + } + if (type === 'Hashtag') { + return 'tag'; + } + if (activitypub._constants.acceptedPostTypes.includes(type)) { + return 'post'; + } +}; + +Helpers.resolveObjects = async (ids) => { + if (!Array.isArray(ids)) { + ids = [ids]; + } + const objects = await Promise.all(ids.map(async (id) => { + // try to get a local ID first + const { type, id: resolvedId, activity, data: activityData } = await Helpers.resolveLocalId(id); + // activity data is only resolved for local IDs - so this will be false for remote posts + if (activity) { + return Helpers.resolveActivity(activity, activityData, id, { type, id: resolvedId }); + } + switch (type) { + case 'user': { + if (!await user.exists(resolvedId)) { + throw new Error('[[error:activitypub.invalid-id]]'); + } + return activitypub.mocks.actors.user(resolvedId); + } + case 'post': { + const post = (await posts.getPostSummaryByPids( + [resolvedId], + activitypub._constants.uid, + { stripTags: false } + )).pop(); + if (!post) { + throw new Error('[[error:activitypub.invalid-id]]'); + } + return activitypub.mocks.note(post); + } + case 'category': { + if (!await categories.exists(resolvedId)) { + throw new Error('[[error:activitypub.invalid-id]]'); + } + return activitypub.mocks.actors.category(resolvedId); + } + // if the type is not recognized, assume it's not a local ID and fetch the object from its origin + default: { + return activitypub.get('uid', 0, id); + } + } + })); + return objects.length === 1 ? objects[0] : objects; +}; + +Helpers.generateTitle = (html) => { + // Given an html string, generates a more appropriate title if possible + const $ = cheerio.load(html); + let title; + + // Try the first paragraph element + title = $('h1, h2, h3, h4, h5, h6, title, p, span').first().text(); + + // Fall back to newline splitting (i.e. if no paragraph elements) + title = title || html.split('\n').filter(Boolean).shift(); + + // Split sentences and use only first one + const sentences = title + .split(/(\.|\?|!)\s/) + .reduce((memo, cur, idx, sentences) => { + if (idx % 2) { + memo.push(`${sentences[idx - 1]}${cur}`); + } + + return memo; + }, []); + + if (sentences.length > 1) { + title = sentences.shift(); + } + + // Truncate down if too long + if (title.length > meta.config.maximumTitleLength) { + title = `${title.slice(0, meta.config.maximumTitleLength - 3)}...`; + } + + return title; +}; diff --git a/src/activitypub/inbox.js b/src/activitypub/inbox.js new file mode 100644 index 000000000000..6978e9541bec --- /dev/null +++ b/src/activitypub/inbox.js @@ -0,0 +1,494 @@ +'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, + }, + }); +} + +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 response = await activitypub.notes.assert(0, object); + if (response) { + winston.verbose(`[activitypub/inbox] Parsing ${response.count} notes into topic ${response.tid}`); + + // todo: put this somewhere better if need be... maybe this is better as api.activitypub.announce.note? + const cid = await topics.getTopicField(response.tid, 'cid'); + const followers = await activitypub.notes.getCategoryFollowers(cid); + if (followers.length) { + await activitypub.send('cid', cid, followers, { + id: `${object.id}#activity/announce`, + type: 'Announce', + to: [`${nconf.get('url')}/category/${cid}/followers`], + cc: [activitypub._constants.publicAddress], + object, + }); + } + } +}; + +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 { + await activitypub.notes.assert(0, object.id); + } + } catch (e) { + reject('Update', object, actor); + } + break; + } + + case 'Person': { + await activitypub.actors.assert(object.id, { update: true }); + break; + } + + case 'Tombstone': { + const [isNote/* , isActor */] = await Promise.all([ + posts.exists(object.id), + // db.isSortedSetMember('usersRemote:lastCrawled', object.id), + ]); + + switch (true) { + case isNote: { + await api.posts.delete({ uid: actor }, { pid: object.id }); + break; + } + + // case isActor: { + // console.log('actor'); + // break; + // } + } + } + } +}; + +inbox.delete = async (req) => { + const { actor, object } = req.body; + + // Deletes don't have their objects resolved automatically + let method = 'purge'; + try { + const { type } = await activitypub.get('uid', 0, object); + if (type === 'Tombstone') { + method = 'delete'; + } + } catch (e) { + // probably 410/404 + } + + // Origin checking + const actorHostname = new URL(actor).hostname; + const objectHostname = new URL(object).hostname; + if (actorHostname !== objectHostname) { + throw new Error('[[error:activitypub.origin-mismatch]]'); + } + + const [isNote/* , isActor */] = await Promise.all([ + posts.exists(object), + // db.isSortedSetMember('usersRemote:lastCrawled', object.id), + ]); + + switch (true) { + case isNote: { + const uid = await posts.getPostField(object, 'uid'); + await 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.info(`[activitypub/inbox.like] ${id} not allowed to be upvoted.`); + return reject('Like', object, actor); + } + + winston.info(`[activitypub/inbox/like] id ${id} via ${actor}`); + + const result = await posts.upvote(id, actor); + 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 + const numFollowers = await activitypub.actors.getLocalFollowersCount(actor); + if (!numFollowers) { + winston.info(`[activitypub/inbox.announce] Rejecting ${object.id} via ${actor} due to no followers`); + reject('Announce', object, actor); + return; + } + + // Handle case where Announce(Create(Note)) is received + if (object.type === 'Create' && object.object.type === 'Note') { + pid = object.object.id; + } else { + pid = object.id; + } + + pid = await activitypub.resolveId(0, pid); // in case wrong id is passed-in; unlikely, but still. + if (!pid) { + return; + } + + ({ tid } = await activitypub.notes.assert(0, pid, { cid, skipChecks: true })); // checks skipped; done above. + if (!tid) { + return; + } + + await topics.updateLastPostTime(tid, timestamp); + await activitypub.notes.updateLocalRecipients(pid, { to, cc }); + await activitypub.notes.syncUserInboxes(tid); + } + + winston.info(`[activitypub/inbox/announce] Parsing id ${pid}`); + + if (!cid) { // Topic events from actors followed by users only + await activitypub.notes.announce.add(pid, actor, timestamp); + } +}; + +inbox.follow = async (req) => { + const { actor, object, id: followId } = req.body; + // Sanity checks + const { type, id } = await helpers.resolveLocalId(object.id); + if (!['category', 'user'].includes(type)) { + throw new Error('[[error:activitypub.invalid-id]]'); + } + + const assertion = await activitypub.actors.assert(actor); + if (!assertion) { + throw new Error('[[error:activitypub.invalid-id]]'); + } + const handle = await user.getUserField(actor, 'username'); + + if (type === 'user') { + const exists = await user.exists(id); + if (!exists) { + throw new Error('[[error:invalid-uid]]'); + } + + const isFollowed = await inbox.isFollowed(actor, id); + if (isFollowed) { + // No additional parsing required + return; + } + + const now = Date.now(); + await db.sortedSetAdd(`followersRemote:${id}`, now, actor); + + const followerRemoteCount = await db.sortedSetCard(`followersRemote:${id}`); + await user.setUserField(id, 'followerRemoteCount', followerRemoteCount); + + user.onFollow(actor, id); + activitypub.send('uid', id, actor, { + id: `${nconf.get('url')}/${type}/${id}#activity/accept:follow/${handle}`, + type: 'Accept', + object: { + id: followId, + type: 'Follow', + actor, + object: object.id, + }, + }); + } 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}`, + type: 'Accept', + object: { + id: followId, + type: 'Follow', + actor, + object: object.id, + }, + }); + } +}; + +inbox.isFollowed = async (actorId, uid) => { + if (actorId.indexOf('@') === -1 || parseInt(uid, 10) <= 0) { + return false; + } + return await db.isSortedSetMember(`followersRemote:${uid}`, actorId); +}; + +inbox.accept = async (req) => { + const { actor, object } = req.body; + const { type } = object; + + const { type: localType, id } = await helpers.resolveLocalId(object.actor); + if (!['user', 'category'].includes(localType)) { + throw new Error('[[error:invalid-data]]'); + } + + const assertion = await activitypub.actors.assert(actor); + if (!assertion) { + throw new Error('[[error:activitypub.invalid-id]]'); + } + + if (type === 'Follow') { + if (localType === 'user') { + if (!await db.isSortedSetMember(`followRequests:uid.${id}`, actor)) { + if (await db.isSortedSetMember(`followingRemote:${id}`, actor)) return; // already following + return reject('Accept', req.body, actor); // not following, not requested, so reject to hopefully stop retries + } + const now = Date.now(); + await Promise.all([ + db.sortedSetRemove(`followRequests:uid.${id}`, actor), + db.sortedSetAdd(`followingRemote:${id}`, now, actor), + db.sortedSetAdd(`followersRemote:${actor}`, now, id), // for followers backreference and notes assertion checking + ]); + const followingRemoteCount = await db.sortedSetCard(`followingRemote:${id}`); + await user.setUserField(id, 'followingRemoteCount', followingRemoteCount); + } else if (localType === 'category') { + if (!await db.isSortedSetMember(`followRequests:cid.${id}`, actor)) { + if (await db.isSortedSetMember(`cid:${id}:following`, actor)) return; // already following + return reject('Accept', req.body, actor); // not following, not requested, so reject to hopefully stop retries + } + const now = Date.now(); + await Promise.all([ + db.sortedSetRemove(`followRequests:cid.${id}`, actor), + db.sortedSetAdd(`cid:${id}:following`, now, actor), + db.sortedSetAdd(`followersRemote:${actor}`, now, `cid|${id}`), // for notes assertion checking + ]); + } + } +}; + +inbox.undo = async (req) => { + // todo: "actor" in this case should be the one in object, no? + const { actor, object } = req.body; + const { type } = object; + + if (actor !== object.actor) { + throw new Error('[[error:activitypub.actor-mismatch]]'); + } + + const assertion = await activitypub.actors.assert(actor); + if (!assertion) { + throw new Error('[[error:activitypub.invalid-id]]'); + } + + let { type: localType, id } = await helpers.resolveLocalId(object.object); + + winston.info(`[activitypub/inbox/undo] ${type} ${localType && id ? `${localType} ${id}` : object.object} via ${actor}`); + + switch (type) { + case 'Follow': { + switch (localType) { + case 'user': { + const exists = await user.exists(id); + if (!exists) { + throw new Error('[[error:invalid-uid]]'); + } + + await db.sortedSetRemove(`followersRemote:${id}`, actor); + const followerRemoteCount = await db.sortedSetCard(`followerRemote:${id}`); + await user.setUserField(id, 'followerRemoteCount', followerRemoteCount); + notifications.rescind(`follow:${id}:uid:${actor}`); + break; + } + + case 'category': { + const exists = await categories.exists(id); + if (!exists) { + throw new Error('[[error:invalid-cid]]'); + } + + await user.setCategoryWatchState(actor, id, categories.watchStates.notwatching); + break; + } + } + + break; + } + + case 'Like': { + const exists = await posts.exists(id); + if (localType !== 'post' || !exists) { + throw new Error('[[error:invalid-pid]]'); + } + + const allowed = await privileges.posts.can('posts:upvote', id, activitypub._constants.uid); + if (!allowed) { + winston.info(`[activitypub/inbox.like] ${id} not allowed to be upvoted.`); + reject('Like', object, actor); + break; + } + + await posts.unvote(id, actor); + 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.`); + } + + 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); +}; diff --git a/src/activitypub/index.js b/src/activitypub/index.js new file mode 100644 index 000000000000..0e46941bb7e0 --- /dev/null +++ b/src/activitypub/index.js @@ -0,0 +1,336 @@ +'use strict'; + +const nconf = require('nconf'); +const winston = require('winston'); +const { createHash, createSign, createVerify, getHashes } = require('crypto'); + +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({ ttl: 1000 * 60 * 5 }); // 5 minutes +const ActivityPub = module.exports; + +ActivityPub._constants = Object.freeze({ + uid: -2, + publicAddress: 'https://www.w3.org/ns/activitystreams#Public', + acceptableTypes: [ + 'application/activity+json', + 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', + ], + acceptedPostTypes: [ + 'Note', 'Page', 'Article', 'Question', + ], + acceptedProtocols: ['https', ...(process.env.CI === 'true' ? ['http'] : [])], +}); +ActivityPub._cache = requestCache; + +ActivityPub.helpers = require('./helpers'); +ActivityPub.inbox = require('./inbox'); +ActivityPub.mocks = require('./mocks'); +ActivityPub.notes = require('./notes'); +ActivityPub.actors = require('./actors'); + +ActivityPub.resolveId = async (uid, id) => { + try { + const query = new URL(id); + ({ id } = await ActivityPub.get('uid', uid, id)); + const response = new URL(id); + + if (query.host !== response.host) { + winston.warn(`[activitypub/resolveId] id resolution domain mismatch: ${query.href} != ${response.href}`); + return null; + } + + return id; + } catch (e) { + return null; + } +}; + +ActivityPub.resolveInboxes = async (ids) => { + const inboxes = new Set(); + + if (!meta.config.activitypubAllowLoopback) { + ids = ids.filter((id) => { + const { hostname } = new URL(id); + return hostname !== nconf.get('url_parsed').hostname; + }); + } + + await ActivityPub.actors.assert(ids); + await Promise.all(ids.map(async (id) => { + const { inbox, sharedInbox } = await user.getUserFields(id, ['inbox', 'sharedInbox']); + if (sharedInbox || inbox) { + inboxes.add(sharedInbox || inbox); + } + })); + + return Array.from(inboxes); +}; + +ActivityPub.getPublicKey = async (type, id) => { + let publicKey; + + try { + ({ publicKey } = await db.getObject(`${type}:${id}:keys`)); + } catch (e) { + ({ publicKey } = await ActivityPub.helpers.generateKeys(type, id)); + } + + return publicKey; +}; + +ActivityPub.getPrivateKey = async (type, id) => { + // Sanity checking + if (!['cid', 'uid'].includes(type) || !utils.isNumber(id) || parseInt(id, 10) < 0) { + throw new Error('[[error:invalid-data]]'); + } + id = parseInt(id, 10); + let privateKey; + + try { + ({ privateKey } = await db.getObject(`${type}:${id}:keys`)); + } catch (e) { + ({ privateKey } = await ActivityPub.helpers.generateKeys(type, id)); + } + + let keyId; + if (type === 'uid') { + keyId = `${nconf.get('url')}${id > 0 ? `/uid/${id}` : '/actor'}#key`; + } else { + keyId = `${nconf.get('url')}/category/${id}#key`; + } + + return { key: privateKey, keyId }; +}; + +ActivityPub.fetchPublicKey = async (uri) => { + // Used for retrieving the public key from the passed-in keyId uri + const body = await ActivityPub.get('uid', 0, uri); + + if (!body.hasOwnProperty('publicKey')) { + throw new Error('[[error:activitypub.pubKey-not-found]]'); + } + + return body.publicKey; +}; + +ActivityPub.sign = async ({ key, keyId }, url, payload) => { + // Returns string for use in 'Signature' header + const { host, pathname } = new URL(url); + const date = new Date().toUTCString(); + let digest = null; + + let headers = '(request-target) host date'; + let signed_string = `(request-target): ${payload ? 'post' : 'get'} ${pathname}\nhost: ${host}\ndate: ${date}`; + + // Calculate payload hash if payload present + if (payload) { + const payloadHash = createHash('sha256'); + payloadHash.update(JSON.stringify(payload)); + digest = `SHA-256=${payloadHash.digest('base64')}`; + headers += ' digest'; + signed_string += `\ndigest: ${digest}`; + } + + // Sign string using private key + let signature = createSign('sha256'); + signature.update(signed_string); + signature.end(); + signature = signature.sign(key, 'base64'); + + // Construct signature header + return { + date, + digest, + signature: `keyId="${keyId}",headers="${headers}",signature="${signature}",algorithm="hs2019"`, + }; +}; + +ActivityPub.verify = async (req) => { + winston.verbose('[activitypub/verify] Starting signature verification...'); + if (!req.headers.hasOwnProperty('signature')) { + winston.verbose('[activitypub/verify] Failed, no signature header.'); + return false; + } + + // Break the signature apart + let { keyId, headers, signature, algorithm, created, expires } = req.headers.signature.split(',').reduce((memo, cur) => { + const split = cur.split('="'); + const key = split.shift(); + const value = split.join('="'); + memo[key] = value.slice(0, -1); + return memo; + }, {}); + + const acceptableHashes = getHashes(); + if (algorithm === 'hs2019' || !acceptableHashes.includes(algorithm)) { + algorithm = 'sha256'; + } + + // Re-construct signature string + const signed_string = headers.split(' ').reduce((memo, cur) => { + switch (cur) { + case '(request-target)': { + memo.push(`${cur}: ${String(req.method).toLowerCase()} ${req.baseUrl}${req.path}`); + break; + } + + case '(created)': { + memo.push(`${cur}: ${created}`); + break; + } + + case '(expires)': { + memo.push(`${cur}: ${expires}`); + break; + } + + default: { + memo.push(`${cur}: ${req.headers[cur]}`); + break; + } + } + + return memo; + }, []).join('\n'); + + // Verify the signature string via public key + try { + // Retrieve public key from remote instance + winston.verbose(`[activitypub/verify] Retrieving pubkey for ${keyId}`); + const { publicKeyPem } = await ActivityPub.fetchPublicKey(keyId); + + const verify = createVerify('sha256'); + verify.update(signed_string); + verify.end(); + winston.verbose('[activitypub/verify] Attempting signed string verification'); + const verified = verify.verify(publicKeyPem, signature, 'base64'); + return verified; + } catch (e) { + winston.verbose('[activitypub/verify] Failed, key retrieval or verification failure.'); + return false; + } +}; + +ActivityPub.get = async (type, id, uri) => { + const cacheKey = [id, uri].join(';'); + if (requestCache.has(cacheKey)) { + return requestCache.get(cacheKey); + } + + const keyData = await ActivityPub.getPrivateKey(type, id); + const headers = id >= 0 ? await ActivityPub.sign(keyData, uri) : {}; + winston.verbose(`[activitypub/get] ${uri}`); + try { + const { response, body } = await request.get(uri, { + headers: { + ...headers, + Accept: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', + }, + timeout: 5000, + }); + + if (!String(response.statusCode).startsWith('2')) { + winston.error(`[activitypub/get] Received ${response.statusCode} when querying ${uri}`); + if (body.hasOwnProperty('error')) { + winston.error(`[activitypub/get] Error received: ${body.error}`); + } + + throw new Error(`[[error:activitypub.get-failed]]`); + } + + requestCache.set(cacheKey, body); + return body; + } catch (e) { + // Handle things like non-json body, etc. + throw new Error(`[[error:activitypub.get-failed]]`); + } +}; + +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}`]), + ]); +}; diff --git a/src/activitypub/mocks.js b/src/activitypub/mocks.js new file mode 100644 index 000000000000..ad48b5abb473 --- /dev/null +++ b/src/activitypub/mocks.js @@ -0,0 +1,393 @@ +'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 = { + allowedClasses: { + '*': [], + }, +}; + +Mocks.profile = async (actors) => { + // Should only ever be called by activitypub.actors.assert + const profiles = (await Promise.all(actors.map(async (actor) => { + if (!actor) { + return null; + } + + const uid = actor.id; + let { + url, preferredUsername, published, icon, image, + name, summary, followers, followerCount, followingCount, + postcount, inbox, endpoints, + } = actor; + preferredUsername = preferredUsername || slugify(name); + const { hostname } = new URL(actor.id); + + let picture; + if (icon) { + picture = typeof icon === 'string' ? icon : icon.url; + } + const iconBackgrounds = await user.getIconBackgrounds(); + let bgColor = Array.prototype.reduce.call(preferredUsername, (cur, next) => cur + next.charCodeAt(), 0); + bgColor = iconBackgrounds[bgColor % iconBackgrounds.length]; + + const payload = { + uid, + username: `${preferredUsername}@${hostname}`, + userslug: `${preferredUsername}@${hostname}`, + displayname: name, + fullname: name, + joindate: new Date(published).getTime(), + picture, + status: 'offline', + 'icon:text': (preferredUsername[0] || '').toUpperCase(), + 'icon:bgColor': bgColor, + uploadedpicture: undefined, + 'cover:url': !image || typeof image === 'string' ? image : image.url, + 'cover:position': '50% 50%', + aboutme: summary, + postcount, + followerCount, + followingCount, + + url, + inbox, + sharedInbox: endpoints ? endpoints.sharedInbox : null, + followersUrl: followers, + }; + + return payload; + }))); + + return profiles; +}; + +Mocks.post = async (objects) => { + let single = false; + if (!Array.isArray(objects)) { + single = true; + objects = [objects]; + } + + const posts = await Promise.all(objects.map(async (object) => { + if (!activitypub._constants.acceptedPostTypes.includes(object.type)) { + return null; + } + + let { + id: pid, + url, + attributedTo: uid, + inReplyTo: toPid, + published, updated, name, content, sourceContent, + to, cc, attachment, tag, + // conversation, // mastodon-specific, ignored. + } = object; + + const resolved = await activitypub.helpers.resolveLocalId(toPid); + if (resolved.type === 'post') { + toPid = resolved.id; + } + const timestamp = new Date(published).getTime(); + let edited = new Date(updated); + edited = Number.isNaN(edited.valueOf()) ? undefined : edited; + + content = sanitize(content, sanitizeConfig); + + const payload = { + uid, + pid, + // tid, --> purposely omitted + name, + content, + sourceContent, + timestamp, + toPid, + + edited, + editor: edited ? uid : undefined, + _activitypub: { to, cc, attachment, tag, url }, + }; + + return payload; + })); + + return single ? posts.pop() : posts; +}; + +Mocks.actors = {}; + +Mocks.actors.user = async (uid) => { + let { username, userslug, displayname, fullname, aboutme, picture, 'cover:url': cover } = await user.getUserData(uid); + const publicKey = await activitypub.getPublicKey('uid', uid); + + if (picture) { + const imagePath = await user.getLocalAvatarPath(uid); + picture = { + type: 'Image', + mediaType: mime.getType(imagePath), + url: `${nconf.get('url')}${picture}`, + }; + } + + if (cover) { + const imagePath = await user.getLocalCoverPath(uid); + cover = { + type: 'Image', + mediaType: mime.getType(imagePath), + url: `${nconf.get('url')}${cover}`, + }; + } + + return { + '@context': 'https://www.w3.org/ns/activitystreams', + id: `${nconf.get('url')}/uid/${uid}`, + url: `${nconf.get('url')}/user/${userslug}`, + followers: `${nconf.get('url')}/uid/${uid}/followers`, + following: `${nconf.get('url')}/uid/${uid}/following`, + inbox: `${nconf.get('url')}/uid/${uid}/inbox`, + outbox: `${nconf.get('url')}/uid/${uid}/outbox`, + sharedInbox: `${nconf.get('url')}/inbox`, + + type: 'Person', + name: username !== displayname ? fullname : username, // displayname is escaped, fullname is not + preferredUsername: userslug, + summary: aboutme, + icon: picture, + image: cover, + + publicKey: { + id: `${nconf.get('url')}/uid/${uid}#key`, + owner: `${nconf.get('url')}/uid/${uid}`, + publicKeyPem: publicKey, + }, + }; +}; + +Mocks.actors.category = async (cid) => { + let { + name, handle: preferredUsername, slug, + description: summary, backgroundImage, + } = await categories.getCategoryData(cid); + const publicKey = await activitypub.getPublicKey('cid', cid); + + backgroundImage = backgroundImage || meta.config['brand:logo'] || `${nconf.get('relative_path')}/assets/logo.png`; + const filename = path.basename(utils.decodeHTMLEntities(backgroundImage)); + backgroundImage = { + type: 'Image', + mediaType: mime.getType(filename), + url: `${nconf.get('url')}${utils.decodeHTMLEntities(backgroundImage)}`, + }; + + return { + '@context': 'https://www.w3.org/ns/activitystreams', + id: `${nconf.get('url')}/category/${cid}`, + url: `${nconf.get('url')}/category/${slug}`, + // followers: , + // following: , + inbox: `${nconf.get('url')}/category/${cid}/inbox`, + outbox: `${nconf.get('url')}/category/${cid}/outbox`, + sharedInbox: `${nconf.get('url')}/inbox`, + + type: 'Group', + name, + preferredUsername, + summary, + icon: backgroundImage, + + publicKey: { + id: `${nconf.get('url')}/category/${cid}#key`, + owner: `${nconf.get('url')}/category/${cid}`, + publicKeyPem: publicKey, + }, + }; +}; + +Mocks.note = async (post) => { + const id = `${nconf.get('url')}/post/${post.pid}`; + + // Return a tombstone for a deleted post + if (post.deleted === true) { + return Mocks.tombstone({ + id, + formerType: 'Note', + attributedTo: `${nconf.get('url')}/uid/${post.user.uid}`, + context: `${nconf.get('url')}/topic/${post.topic.tid}`, + audience: `${nconf.get('url')}/category/${post.category.cid}`, + }); + } + + const published = new Date(parseInt(post.timestamp, 10)).toISOString(); + + // todo: post visibility + const to = new Set([activitypub._constants.publicAddress]); + const cc = new Set([`${nconf.get('url')}/uid/${post.user.uid}/followers`]); + + let inReplyTo = null; + let tag = null; + let followersUrl; + + let name = null; + ({ titleRaw: name } = await topics.getTopicFields(post.tid, ['title'])); + + if (post.toPid) { // direct reply + inReplyTo = utils.isNumber(post.toPid) ? `${nconf.get('url')}/post/${post.toPid}` : post.toPid; + name = `Re: ${name}`; + + const parentId = await posts.getPostField(post.toPid, 'uid'); + followersUrl = await user.getUserField(parentId, ['followersUrl']); + to.add(utils.isNumber(parentId) ? `${nconf.get('url')}/uid/${parentId}` : parentId); + } else if (!post.isMainPost) { // reply to OP + inReplyTo = utils.isNumber(post.topic.mainPid) ? `${nconf.get('url')}/post/${post.topic.mainPid}` : post.topic.mainPid; + name = `Re: ${name}`; + + to.add(utils.isNumber(post.topic.uid) ? `${nconf.get('url')}/uid/${post.topic.uid}` : post.topic.uid); + followersUrl = await user.getUserField(post.topic.uid, ['followersUrl']); + } else { // new topic + tag = post.topic.tags.map(tag => ({ + type: 'Hashtag', + href: `${nconf.get('url')}/tags/${tag.valueEncoded}`, + name: `#${tag.value}`, + })); + } + + if (followersUrl) { + cc.add(followersUrl); + } + + const content = await posts.getPostField(post.pid, 'content'); + const { postData: parsed } = await plugins.hooks.fire('filter:parse.post', { + postData: { content }, + type: 'activitypub.note', + }); + post.content = sanitize(parsed.content, sanitizeConfig); + post.content = posts.relativeToAbsolute(post.content, posts.urlRegex); + post.content = posts.relativeToAbsolute(post.content, posts.imgRegex); + + let source = null; + const [markdownEnabled, mentionsEnabled] = await Promise.all([ + plugins.isActive('nodebb-plugin-markdown'), + plugins.isActive('nodebb-plugin-mentions'), + ]); + if (markdownEnabled) { + const raw = await posts.getPostField(post.pid, 'content'); + source = { + content: raw, + mediaType: 'text/markdown', + }; + } + if (mentionsEnabled) { + const mentions = require.main.require('nodebb-plugin-mentions'); + const matches = await mentions.getMatches(post.content); + + if (matches.size) { + tag = tag || []; + tag.push(...Array.from(matches).map(({ id: href, slug: name }) => { + if (utils.isNumber(href)) { // local ref + name = name.toLowerCase(); // local slugs are always lowercase + href = `${nconf.get('url')}/user/${name.slice(1)}`; + name = `${name}@${nconf.get('url_parsed').hostname}`; + } + + return { + type: 'Mention', + href, + name, + }; + })); + + Array.from(matches) + .reduce((ids, { id }) => { + if (!utils.isNumber(id) && !to.has(id) && !cc.has(id)) { + ids.push(id); + } + + return ids; + }, []) + .forEach(id => cc.add(id)); + } + } + + let attachment = await posts.attachments.get(post.pid) || []; + const uploads = await posts.uploads.listWithSizes(post.pid); + uploads.forEach(({ name, width, height }) => { + const mediaType = mime.getType(name); + const url = `${nconf.get('url') + nconf.get('upload_url')}/${name}`; + attachment.push({ mediaType, url, width, height }); + }); + + attachment = attachment.map(({ mediaType, url, width, height }) => { + let type; + + switch (true) { + case mediaType.startsWith('image'): { + type = 'Image'; + break; + } + + default: { + type = 'Link'; + break; + } + } + + const payload = { type, mediaType, url }; + + if (width || height) { + payload.width = width; + payload.height = height; + } + + return payload; + }); + + const object = { + '@context': 'https://www.w3.org/ns/activitystreams', + id, + type: 'Note', + to: Array.from(to), + cc: Array.from(cc), + inReplyTo, + published, + url: id, + attributedTo: `${nconf.get('url')}/uid/${post.user.uid}`, + context: `${nconf.get('url')}/topic/${post.topic.tid}`, + audience: `${nconf.get('url')}/category/${post.category.cid}`, + sensitive: false, // todo + summary: null, + name, + content: post.content, + source, + tag, + attachment, + // replies: {} todo... + }; + + return object; +}; + +Mocks.tombstone = async properties => ({ + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Tombstone', + ...properties, +}); diff --git a/src/activitypub/notes.js b/src/activitypub/notes.js new file mode 100644 index 000000000000..9829ebc9d640 --- /dev/null +++ b/src/activitypub/notes.js @@ -0,0 +1,374 @@ +'use strict'; + +const winston = require('winston'); +const nconf = require('nconf'); + +const db = require('../database'); +const meta = require('../meta'); +const privileges = require('../privileges'); +const categories = require('../categories'); +const user = require('../user'); +const topics = require('../topics'); +const posts = require('../posts'); +const utils = require('../utils'); + +const activitypub = module.parent.exports; +const Notes = module.exports; + +async function lock(value) { + const count = await db.incrObjectField('locks', value); + return count <= 1; +} + +async function unlock(value) { + await db.deleteObjectField('locks', value); +} + +Notes.assert = async (uid, input, options = { skipChecks: false }) => { + /** + * Given the id or object of any as:Note, traverses up to cache the entire threaded context + * + * Unfortunately, due to limitations and fragmentation of the existing ActivityPub landscape, + * retrieving the entire reply tree is not possible at this time. + */ + + const object = !activitypub.helpers.isUri(input) && input; + const id = object ? object.id : input; + + const lockStatus = await lock(id, '[[error:activitypub.already-asserting]]'); + if (!lockStatus) { // unable to achieve lock, stop processing. + return null; + } + + const chain = Array.from(await Notes.getParentChain(uid, input)); + if (!chain.length) { + unlock(id); + return null; + } + + const mainPost = chain[chain.length - 1]; + let { pid: mainPid, tid, uid: authorId, timestamp, name, content } = mainPost; + const hasTid = !!tid; + + const members = await db.isSortedSetMembers(`tid:${tid}:posts`, chain.slice(0, -1).map(p => p.pid)); + members.push(await posts.exists(mainPid)); + if (tid && members.every(Boolean)) { + // All cached, return early. + winston.verbose('[notes/assert] No new notes to process.'); + unlock(id); + return { tid, count: 0 }; + } + + let cid; + let title; + if (hasTid) { + ({ cid, mainPid } = await topics.getTopicFields(tid, ['tid', 'cid', 'mainPid'])); + + if (options.cid && cid === -1) { + // Move topic + await topics.tools.move(tid, { cid: options.cid, uid: 'system' }); + } + } else { + // mainPid ok to leave as-is + cid = options.cid || -1; + title = name || activitypub.helpers.generateTitle(utils.decodeHTMLEntities(content)); + } + mainPid = utils.isNumber(mainPid) ? parseInt(mainPid, 10) : mainPid; + + // Relation & privilege check for local categories + const hasRelation = uid || options.skipChecks || options.cid || hasTid || await assertRelation(chain[0]); + const privilege = `topics:${tid ? 'reply' : 'create'}`; + const allowed = await privileges.categories.can(privilege, cid, activitypub._constants.uid); + if (!hasRelation || !allowed) { + if (!hasRelation) { + winston.info(`[activitypub/notes.assert] Not asserting ${id} as it has no relation to existing tracked content.`); + } + + unlock(id); + return null; + } + + tid = tid || utils.generateUUID(); + mainPost.tid = tid; + + const unprocessed = chain.map((post) => { + post.tid = tid; // add tid to post hash + return post; + }).filter((p, idx) => !members[idx]); + const count = unprocessed.length; + winston.verbose(`[notes/assert] ${count} new note(s) found.`); + + const [ids, timestamps] = [ + unprocessed.map(n => (utils.isNumber(n.pid) ? parseInt(n.pid, 10) : n.pid)), + unprocessed.map(n => n.timestamp), + ]; + + // mainPid doesn't belong in posts zset + if (ids.includes(mainPid)) { + const idx = ids.indexOf(mainPid); + ids.splice(idx, 1); + timestamps.splice(idx, 1); + } + + let tags; + if (!hasTid) { + const { to, cc, attachment } = mainPost._activitypub; + const systemTags = (meta.config.systemTags || '').split(','); + const maxTags = await categories.getCategoryField(cid, 'maxTags'); + tags = (mainPost._activitypub.tag || []) + .filter(o => o.type === 'Hashtag' && !systemTags.includes(o.name.slice(1))) + .map(o => o.name.slice(1)); + + if (maxTags && tags.length > maxTags) { + tags.length = maxTags; + } + + await Promise.all([ + topics.post({ + tid, + uid: authorId, + cid, + pid: mainPid, + title, + timestamp, + tags, + content: mainPost.content, + _activitypub: mainPost._activitypub, + }), + Notes.updateLocalRecipients(mainPid, { to, cc }), + posts.attachments.update(mainPid, attachment), + ]); + unprocessed.pop(); + } + + unprocessed.reverse(); + for (const post of unprocessed) { + const { to, cc, attachment } = post._activitypub; + + // eslint-disable-next-line no-await-in-loop + await Promise.all([ + topics.reply(post), + Notes.updateLocalRecipients(post.pid, { to, cc }), + posts.attachments.update(post.pid, attachment), + ]); + + // Category announce + if (object && object.id === post.pid) { + // eslint-disable-next-line no-await-in-loop + const followers = await activitypub.notes.getCategoryFollowers(cid); + // eslint-disable-next-line no-await-in-loop + await activitypub.send('cid', cid, followers, { + id: `${object.id}#activity/announce`, + type: 'Announce', + to: [`${nconf.get('url')}/category/${cid}/followers`], + cc: [activitypub._constants.publicAddress], + object, + }); + } + } + + await Promise.all([ + Notes.syncUserInboxes(tid, uid), + unlock(id), + ]); + + return { tid, count }; +}; + +async function assertRelation(post) { + /** + * Given a mocked post object, ensures that it is related to some other object in database + * This check ensures that random content isn't added to the database just because it is received. + */ + + // Is followed by at least one local user + const numFollowers = await activitypub.actors.getLocalFollowersCount(post.uid); + + // Local user is mentioned + const { tag } = post._activitypub; + let uids = []; + if (tag && tag.length) { + const slugs = tag.reduce((slugs, tag) => { + if (tag.type === 'Mention') { + const [slug, hostname] = tag.name.slice(1).split('@'); + if (hostname === nconf.get('url_parsed').hostname) { + slugs.push(slug); + } + } + return slugs; + }, []); + + uids = slugs.length ? await db.sortedSetScores('userslug:uid', slugs) : []; + uids = uids.filter(Boolean); + } + + return numFollowers > 0 || uids.length; +} + +Notes.updateLocalRecipients = async (id, { to, cc }) => { + const recipients = new Set([...(to || []), ...(cc || [])]); + const uids = new Set(); + await Promise.all(Array.from(recipients).map(async (recipient) => { + const { type, id } = await activitypub.helpers.resolveLocalId(recipient); + if (type === 'user' && await user.exists(id)) { + uids.add(parseInt(id, 10)); + return; + } + + const followedUid = await db.getObjectField('followersUrl:uid', recipient); + if (followedUid) { + const { uids: followers } = await activitypub.actors.getLocalFollowers(followedUid); + if (followers.size > 0) { + followers.forEach((uid) => { + uids.add(uid); + }); + } + } + })); + + if (uids.size > 0) { + await db.setAdd(`post:${id}:recipients`, Array.from(uids)); + } +}; + +Notes.getParentChain = async (uid, input) => { + // Traverse upwards via `inReplyTo` until you find the root-level Note + const id = activitypub.helpers.isUri(input) ? input : input.id; + + const chain = new Set(); + const traverse = async (uid, id) => { + // Handle remote reference to local post + const { type, id: localId } = await activitypub.helpers.resolveLocalId(id); + if (type === 'post' && localId) { + return await traverse(uid, localId); + } + + const exists = await db.exists(`post:${id}`); + if (exists) { + const postData = await posts.getPostData(id); + 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.warn(`[activitypub/notes/getParentChain] Cannot retrieve ${id}, terminating here.`); + } + } + }; + + await traverse(uid, id); + return chain; +}; + +Notes.syncUserInboxes = async function (tid, uid) { + const [pids, { cid, mainPid }] = await Promise.all([ + db.getSortedSetMembers(`tid:${tid}:posts`), + topics.getTopicFields(tid, ['tid', 'cid', 'mainPid']), + ]); + pids.unshift(mainPid); + + const recipients = await db.getSetsMembers(pids.map(id => `post:${id}:recipients`)); + const uids = recipients.reduce((set, uids) => new Set([...set, ...uids.map(u => parseInt(u, 10))]), new Set()); + if (uid) { + uids.add(parseInt(uid, 10)); + } + + const keys = Array.from(uids).map(uid => `uid:${uid}:inbox`); + const score = await db.sortedSetScore(`cid:${cid}:tids`, tid); + + winston.verbose(`[activitypub/syncUserInboxes] Syncing tid ${tid} with ${uids.size} inboxes`); + await Promise.all([ + db.sortedSetsAdd(keys, keys.map(() => score || Date.now()), tid), + db.setAdd(`tid:${tid}:recipients`, Array.from(uids)), + ]); +}; + +Notes.getCategoryFollowers = async (cid) => { + // Retrieves remote users who have followed a category; used to build recipient list + let uids = await db.getSortedSetRangeByScore(`cid:${cid}:uid:watch:state`, 0, -1, categories.watchStates.tracking, categories.watchStates.tracking); + uids = uids.filter(uid => !utils.isNumber(uid)); + + return uids; +}; + +Notes.announce = {}; + +Notes.announce.list = async ({ pid, tid }) => { + let pids = []; + if (pid) { + pids = [pid]; + } else if (tid) { + let mainPid; + ([pids, mainPid] = await Promise.all([ + db.getSortedSetMembers(`tid:${tid}:posts`), + topics.getTopicField(tid, 'mainPid'), + ])); + pids.unshift(mainPid); + } + + if (!pids.length) { + return []; + } + + const keys = pids.map(pid => `pid:${pid}:announces`); + let announces = await db.getSortedSetsMembersWithScores(keys); + announces = announces.reduce((memo, cur, idx) => { + if (cur.length) { + const pid = pids[idx]; + cur.forEach(({ value: actor, score: timestamp }) => { + memo.push({ pid, actor, timestamp }); + }); + } + return memo; + }, []); + + return announces; +}; + +Notes.announce.add = async (pid, actor, timestamp = Date.now()) => { + await db.sortedSetAdd(`pid:${pid}:announces`, timestamp, actor); +}; + +Notes.announce.remove = async (pid, actor) => { + await db.sortedSetRemove(`pid:${pid}:announces`, actor); +}; + +Notes.announce.removeAll = async (pid) => { + await db.delete(`pid:${pid}:announces`); +}; + +Notes.delete = async (pids) => { + if (!Array.isArray(pids)) { + pids = [pids]; + } + + const exists = await posts.exists(pids); + pids = pids.filter((_, idx) => exists[idx]); + + const recipientSets = pids.map(id => `post:${id}:recipients`); + const announcerSets = pids.map(id => `pid:${id}:announces`); + + await db.deleteAll([...recipientSets, ...announcerSets]); +}; diff --git a/src/analytics.js b/src/analytics.js index 73a2f164b815..49c987d52d2b 100644 --- a/src/analytics.js +++ b/src/analytics.js @@ -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) { diff --git a/src/api/activitypub.js b/src/api/activitypub.js new file mode 100644 index 000000000000..cf83da19ff2a --- /dev/null +++ b/src/api/activitypub.js @@ -0,0 +1,334 @@ +'use strict'; + +/** + * DEVELOPMENT NOTE + * + * THIS FILE IS UNDER ACTIVE DEVELOPMENT AND IS EXPLICITLY EXCLUDED FROM IMMUTABILITY GUARANTEES + * + * If you use api methods in this file, be prepared that they may be removed or modified with no warning. + */ + +const nconf = require('nconf'); +const winston = require('winston'); + +const db = require('../database'); +const user = require('../user'); +const meta = require('../meta'); +const privileges = require('../privileges'); +const activitypub = require('../activitypub'); +const posts = require('../posts'); +const utils = require('../utils'); + +const activitypubApi = module.exports; + +function noop() {} + +function enabledCheck(next) { + return async function (caller, params) { + if (!meta.config.activitypubEnabled) { + return noop; + } + + next(caller, params); + }; +} + +activitypubApi.follow = enabledCheck(async (caller, { type, id, actor } = {}) => { + // Privilege checks should be done upstream + const assertion = await activitypub.actors.assert(actor); + if (!assertion) { + throw new Error('[[error:activitypub.invalid-id]]'); + } + + actor = actor.includes('@') ? await user.getUidByUserslug(actor) : actor; + const handle = await user.getUserField(actor, 'username'); + + await activitypub.send(type, id, [actor], { + id: `${nconf.get('url')}/${type}/${id}#activity/follow/${handle}`, + type: 'Follow', + object: actor, + }); + + await db.sortedSetAdd(`followRequests:${type}.${id}`, Date.now(), actor); +}); + +// should be .undo.follow +activitypubApi.unfollow = enabledCheck(async (caller, { type, id, actor }) => { + const assertion = await activitypub.actors.assert(actor); + if (!assertion) { + throw new Error('[[error:activitypub.invalid-id]]'); + } + + actor = actor.includes('@') ? await user.getUidByUserslug(actor) : actor; + const handle = await user.getUserField(actor, 'username'); + + const object = { + id: `${nconf.get('url')}/${type}/${id}#activity/follow/${handle}`, + type: 'Follow', + object: actor, + }; + if (type === 'uid') { + object.actor = `${nconf.get('url')}/uid/${id}`; + } else if (type === 'cid') { + object.actor = `${nconf.get('url')}/category/${id}`; + } + + await activitypub.send(type, id, [actor], { + id: `${nconf.get('url')}/${type}/${id}#activity/undo:follow/${handle}`, + type: 'Undo', + object, + }); + + if (type === 'uid') { + await Promise.all([ + db.sortedSetRemove(`followingRemote:${id}`, actor), + db.decrObjectField(`user:${id}`, 'followingRemoteCount'), + ]); + } else if (type === 'cid') { + await Promise.all([ + db.sortedSetRemove(`cid:${id}:following`, actor), + db.sortedSetRemove(`followRequests:cid.${id}`, actor), + ]); + } +}); + +activitypubApi.create = {}; + +async function buildRecipients(object, { pid, uid }) { + /** + * - Builds a list of targets for activitypub.send to consume + * - Extends to and cc since the activity can be addressed more widely + */ + const followers = await db.getSortedSetMembers(`followersRemote:${uid}`); + let { to, cc } = object; + to = new Set(to); + cc = new Set(cc); + + const targets = new Set([...followers, ...to, ...cc]); + + // Remove any ids that aren't asserted actors + const exists = await db.isSortedSetMembers('usersRemote:lastCrawled', [...targets]); + Array.from(targets).forEach((uri, idx) => { + if (!exists[idx]) { + targets.delete(uri); + } + }); + + // Announcers and their followers + if (pid) { + const announcers = (await activitypub.notes.announce.list({ pid })).map(({ actor }) => actor); + const announcersFollowers = (await user.getUsersFields(announcers, ['followersUrl'])) + .filter(o => o.hasOwnProperty('followersUrl')) + .map(({ followersUrl }) => followersUrl); + [...announcers].forEach(uri => targets.add(uri)); + [...announcers, ...announcersFollowers].forEach(uri => cc.add(uri)); + } + + return { + to: [...to], + cc: [...cc], + targets, + }; +} + +activitypubApi.create.note = enabledCheck(async (caller, { pid }) => { + const post = (await posts.getPostSummaryByPids([pid], caller.uid, { stripTags: false })).pop(); + if (!post) { + return; + } + + const allowed = await privileges.posts.can('topics:read', pid, activitypub._constants.uid); + if (!allowed) { + winston.verbose(`[activitypub/api] Not federating creation of pid ${pid} to the fediverse due to privileges.`); + return; + } + + const object = await activitypub.mocks.note(post); + const { to, cc, targets } = await buildRecipients(object, { uid: post.user.uid }); + const { cid } = post.category; + const followers = await activitypub.notes.getCategoryFollowers(cid); + + const payloads = { + create: { + id: `${object.id}#activity/create`, + type: 'Create', + to, + cc, + object, + }, + announce: { + id: `${object.id}#activity/announce`, + type: 'Announce', + to: [activitypub._constants.publicAddress], + cc: [`${nconf.get('url')}/category/${cid}/followers`], + object, + }, + }; + + await activitypub.send('uid', caller.uid, Array.from(targets), payloads.create); + if (followers.length) { + await activitypub.send('cid', cid, followers, payloads.announce); + } +}); + +activitypubApi.update = {}; + +activitypubApi.update.profile = enabledCheck(async (caller, { uid }) => { + const [object, followers] = await Promise.all([ + activitypub.mocks.actors.user(uid), + db.getSortedSetMembers(`followersRemote:${caller.uid}`), + ]); + + await activitypub.send('uid', caller.uid, followers, { + id: `${object.id}#activity/update/${Date.now()}`, + type: 'Update', + to: [activitypub._constants.publicAddress], + cc: [], + object, + }); +}); + +activitypubApi.update.note = enabledCheck(async (caller, { post }) => { + // Only applies to local posts + if (!utils.isNumber(post.pid)) { + return; + } + + const object = await activitypub.mocks.note(post); + const { to, cc, targets } = await buildRecipients(object, { pid: post.pid, uid: post.user.uid }); + + const allowed = await privileges.posts.can('topics:read', post.pid, activitypub._constants.uid); + if (!allowed) { + winston.verbose(`[activitypub/api] Not federating update of pid ${post.pid} to the fediverse due to privileges.`); + return; + } + + const payload = { + id: `${object.id}#activity/update/${post.edited || Date.now()}`, + type: 'Update', + to, + cc, + object, + }; + + await activitypub.send('uid', caller.uid, Array.from(targets), payload); +}); + +activitypubApi.delete = {}; + +activitypubApi.delete.note = enabledCheck(async (caller, { pid }) => { + // Only applies to local posts + if (!utils.isNumber(pid)) { + return; + } + + const id = `${nconf.get('url')}/post/${pid}`; + const post = (await posts.getPostSummaryByPids([pid], caller.uid, { stripTags: false })).pop(); + const object = await activitypub.mocks.note(post); + const { to, cc, targets } = await buildRecipients(object, { pid, uid: post.user.uid }); + + const allowed = await privileges.posts.can('topics:read', pid, activitypub._constants.uid); + if (!allowed) { + winston.verbose(`[activitypub/api] Not federating update of pid ${pid} to the fediverse due to privileges.`); + return; + } + + const payload = { + id: `${id}#activity/delete/${Date.now()}`, + type: 'Delete', + to, + cc, + object: id, + origin: object.context, + }; + + await activitypub.send('uid', caller.uid, Array.from(targets), payload); +}); + +activitypubApi.like = {}; + +activitypubApi.like.note = enabledCheck(async (caller, { pid }) => { + if (!activitypub.helpers.isUri(pid)) { // remote only + return; + } + + const uid = await posts.getPostField(pid, 'uid'); + if (!activitypub.helpers.isUri(uid)) { + return; + } + + await activitypub.send('uid', caller.uid, [uid], { + id: `${nconf.get('url')}/uid/${caller.uid}#activity/like/${encodeURIComponent(pid)}`, + type: 'Like', + object: pid, + }); +}); + +activitypubApi.undo = {}; + +// activitypubApi.undo.follow = + +activitypubApi.undo.like = enabledCheck(async (caller, { pid }) => { + if (!activitypub.helpers.isUri(pid)) { + return; + } + + const uid = await posts.getPostField(pid, 'uid'); + if (!activitypub.helpers.isUri(uid)) { + return; + } + + await activitypub.send('uid', caller.uid, [uid], { + id: `${nconf.get('url')}/uid/${caller.uid}#activity/undo:like/${encodeURIComponent(pid)}`, + 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}`, + 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); +}); diff --git a/src/api/flags.js b/src/api/flags.js index ffa56e27820c..ff3097898211 100644 --- a/src/api/flags.js +++ b/src/api/flags.js @@ -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) { diff --git a/src/api/helpers.js b/src/api/helpers.js index ef7c0624829d..e0d3bbc0bbf7 100644 --- a/src/api/helpers.js +++ b/src/api/helpers.js @@ -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; } diff --git a/src/api/index.js b/src/api/index.js index c454de93a569..18cd8678f1b8 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -11,6 +11,7 @@ module.exports = { categories: require('./categories'), search: require('./search'), flags: require('./flags'), + activitypub: require('./activitypub'), files: require('./files'), utils: require('./utils'), }; diff --git a/src/api/posts.js b/src/api/posts.js index 603e3bf2aa26..d136c3bd4891 100644 --- a/src/api/posts.js +++ b/src/api/posts.js @@ -12,6 +12,7 @@ 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'); @@ -133,12 +134,14 @@ 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, { extraFields: ['edited'] }); 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); + await require('.').activitypub.update.note(caller, { post: postObj[0] }); + return returnData; } @@ -151,6 +154,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; }; @@ -189,6 +193,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) { @@ -207,16 +216,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; @@ -224,8 +239,11 @@ postsAPI.purge = async function (caller, data) { if (!canPurge) { throw new Error('[[error:no-privileges]]'); } - require('../posts/cache').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']); @@ -390,7 +408,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; diff --git a/src/api/search.js b/src/api/search.js index b9645ee567a4..78e120743f56 100644 --- a/src/api/search.js +++ b/src/api/search.js @@ -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({ diff --git a/src/api/topics.js b/src/api/topics.js index 7a6cabf966a0..4b853afebcdc 100644 --- a/src/api/topics.js +++ b/src/api/topics.js @@ -8,6 +8,7 @@ const posts = require('../posts'); const meta = require('../meta'); const privileges = require('../privileges'); +const activitypubApi = require('./activitypub'); const apiHelpers = require('./helpers'); const { doTopicAction } = apiHelpers; @@ -80,6 +81,10 @@ 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) { + activitypubApi.create.note(caller, { pid: result.postData.pid }); + } + return result.topicData; }; @@ -113,6 +118,7 @@ topicsAPI.reply = async function (caller, data) { } socketHelpers.notifyNew(caller.uid, 'newPost', result); + activitypubApi.create.note(caller, { pid: postData.pid }); return postObj[0]; }; diff --git a/src/api/users.js b/src/api/users.js index 931e75b36b1a..6b3398db9dfd 100644 --- a/src/api/users.js +++ b/src/api/users.js @@ -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/${data.uid}/followers`, - mergeId: 'notifications:user-started-following-you', - }); - if (!notifObj) { - return; - } - notifObj.user = userData; - await notifications.push(notifObj, [data.uid]); }; usersAPI.unfollow = async function (caller, data) { diff --git a/src/cache/lru.js b/src/cache/lru.js index fc6eb691478a..e4fb790c93e4 100644 --- a/src/cache/lru.js +++ b/src/cache/lru.js @@ -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; diff --git a/src/cache/ttl.js b/src/cache/ttl.js index 292c76fdc715..5e1bc2d5cd63 100644 --- a/src/cache/ttl.js +++ b/src/cache/ttl.js @@ -30,7 +30,7 @@ module.exports = function (opts) { }); }); - cache.has = (key) => { + cache.has = function (key) { if (!cache.enabled) { return false; } diff --git a/src/categories/create.js b/src/categories/create.js index c4aa40342553..745c8b9d73cb 100644 --- a/src/categories/create.js +++ b/src/categories/create.js @@ -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']; diff --git a/src/categories/data.js b/src/categories/data.js index 4568d4850d67..452ccf20b867 100644 --- a/src/categories/data.js +++ b/src/categories/data.js @@ -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, diff --git a/src/categories/index.js b/src/categories/index.js index b266788b3aaf..fa62b3250110 100644 --- a/src/categories/index.js +++ b/src/categories/index.js @@ -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]) { @@ -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); }; diff --git a/src/categories/update.js b/src/categories/update.js index d4be83edb8cd..517cbeb499fd 100644 --- a/src/categories/update.js +++ b/src/categories/update.js @@ -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), + ]); + } }; diff --git a/src/categories/watch.js b/src/categories/watch.js index f80d0bf15d94..4f53ea01e586 100644 --- a/src/categories/watch.js +++ b/src/categories/watch.js @@ -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) { diff --git a/src/controllers/accounts/helpers.js b/src/controllers/accounts/helpers.js index 4d58f182736f..ef0c9e14995e 100644 --- a/src/controllers/accounts/helpers.js +++ b/src/controllers/accounts/helpers.js @@ -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]]'); } @@ -78,7 +86,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; @@ -177,6 +186,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`)), @@ -196,6 +206,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.best = counts.best.reduce((sum, count) => sum + count, 0); counts.controversial = counts.controversial.reduce((sum, count) => sum + count, 0); counts.categoriesWatched = counts.categoriesWatched && counts.categoriesWatched.length; @@ -271,7 +282,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); diff --git a/src/controllers/accounts/posts.js b/src/controllers/accounts/posts.js index d1881454cbe2..46ae337a385a 100644 --- a/src/controllers/accounts/posts.js +++ b/src/controllers/accounts/posts.js @@ -177,21 +177,27 @@ async function getPostsFromUserSet(template, req, res) { const data = templateToData[template]; const page = Math.max(1, parseInt(req.query.page, 10) || 1); + // exposeUid returns -2 for all remote users for ease of processing, restoring uid + let { uid } = res.locals; + if (uid === -2) { + uid = await db.getObjectField('handle:uid', req.params.userslug.toLowerCase()); + } + const [{ username, userslug }, settings] = await Promise.all([ - user.getUserFields(res.locals.uid, ['username', 'userslug']), + user.getUserFields(uid, ['username', 'userslug']), 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, diff --git a/src/controllers/accounts/profile.js b/src/controllers/accounts/profile.js index 1ef97567849e..9aec78ee91ed 100644 --- a/src/controllers/accounts/profile.js +++ b/src/controllers/accounts/profile.js @@ -4,6 +4,7 @@ const nconf = require('nconf'); const _ = require('lodash'); const db = require('../../database'); +const meta = require('../../meta'); const user = require('../../user'); const posts = require('../../posts'); const categories = require('../../categories'); @@ -53,7 +54,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); }; @@ -124,7 +130,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 = [ { @@ -161,4 +167,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}`, + }]; + } } diff --git a/src/controllers/activitypub/actors.js b/src/controllers/activitypub/actors.js new file mode 100644 index 000000000000..4391f03cee01 --- /dev/null +++ b/src/controllers/activitypub/actors.js @@ -0,0 +1,137 @@ +'use strict'; + +const nconf = require('nconf'); + +const meta = require('../../meta'); +const privileges = require('../../privileges'); +const posts = require('../../posts'); +const topics = require('../../topics'); +const categories = require('../../categories'); +const activitypub = require('../../activitypub'); +const utils = require('../../utils'); + +const Actors = module.exports; + +Actors.application = async function (req, res) { + const publicKey = await activitypub.getPublicKey('uid', 0); + const name = meta.config.title || 'NodeBB'; + + res.status(200).json({ + '@context': 'https://www.w3.org/ns/activitystreams', + id: `${nconf.get('url')}/actor`, + url: `${nconf.get('url')}/actor`, + inbox: `${nconf.get('url')}/inbox`, + outbox: `${nconf.get('url')}/outbox`, + + type: 'Application', + name, + preferredUsername: nconf.get('url_parsed').hostname, + + publicKey: { + id: `${nconf.get('url')}/actor#key`, + owner: `${nconf.get('url')}/actor`, + publicKeyPem: publicKey, + }, + }); +}; + +Actors.user = async function (req, res) { + // todo: view:users priv gate + const payload = await activitypub.mocks.actors.user(req.params.uid); + + res.status(200).json(payload); +}; + +Actors.userBySlug = async function (req, res) { + const { uid } = res.locals; + req.params.uid = uid; + delete req.params.userslug; + Actors.user(req, res); +}; + +Actors.note = async function (req, res) { + // technically a note isn't an actor, but it is here purely for organizational purposes. + // but also, wouldn't it be wild if you could follow a note? lol. + const allowed = utils.isNumber(req.params.pid) && await privileges.posts.can('topics:read', req.params.pid, activitypub._constants.uid); + const post = (await posts.getPostSummaryByPids([req.params.pid], req.uid, { stripTags: false })).pop(); + if (!allowed || !post) { + return res.sendStatus(404); + } + + const payload = await activitypub.mocks.note(post); + res.status(200).json(payload); +}; + +Actors.topic = async function (req, res, next) { + const allowed = await privileges.topics.can('topics:read', req.params.tid, activitypub._constants.uid); + if (!allowed) { + return res.sendStatus(404); + } + + let page = parseInt(req.query.page, 10); + const { cid, titleRaw: name, mainPid, slug, postcount } = await topics.getTopicFields(req.params.tid, ['cid', 'title', 'mainPid', 'slug', 'postcount']); + const pageCount = Math.max(1, Math.ceil(postcount / meta.config.postsPerPage)); + let items; + let paginate = true; + + if (!page && pageCount === 1) { + page = 1; + paginate = false; + } + + if (page) { + const invalidPagination = page < 1 || page > pageCount; + if (invalidPagination) { + return next(); + } + + const start = Math.max(0, ((page - 1) * meta.config.postsPerPage) - 1); + const stop = Math.max(0, start + meta.config.postsPerPage - 1); + const pids = await posts.getPidsFromSet(`tid:${req.params.tid}:posts`, start, stop); + if (page === 1) { + pids.unshift(mainPid); + pids.length = Math.min(pids.length, meta.config.postsPerPage); + } + items = pids.map(pid => (utils.isNumber(pid) ? `${nconf.get('url')}/post/${pid}` : pid)); + } + + const object = { + '@context': 'https://www.w3.org/ns/activitystreams', + id: `${nconf.get('url')}/topic/${req.params.tid}${paginate && page ? `?page=${page}` : ''}`, + url: `${nconf.get('url')}/topic/${slug}`, + name, + type: paginate && items ? 'OrderedCollectionPage' : 'OrderedCollection', + audience: `${nconf.get('url')}/category/${cid}`, + totalItems: postcount, + }; + + if (items) { + object.items = items; + + if (paginate) { + object.partOf = `${nconf.get('url')}/topic/${req.params.tid}`; + object.next = page < pageCount ? `${nconf.get('url')}/topic/${req.params.tid}?page=${page + 1}` : null; + object.prev = page > 1 ? `${nconf.get('url')}/topic/${req.params.tid}?page=${page - 1}` : null; + } + } + + if (paginate) { + object.first = `${nconf.get('url')}/topic/${req.params.tid}?page=1`; + object.last = `${nconf.get('url')}/topic/${req.params.tid}?page=${pageCount}`; + } + + res.status(200).json(object); +}; + +Actors.category = async function (req, res, next) { + const [exists, allowed] = await Promise.all([ + categories.exists(req.params.cid), + privileges.categories.can('find', req.params.cid, activitypub._constants.uid), + ]); + if (!exists || !allowed) { + return next('route'); + } + + const payload = await activitypub.mocks.actors.category(req.params.cid); + res.status(200).json(payload); +}; diff --git a/src/controllers/activitypub/index.js b/src/controllers/activitypub/index.js new file mode 100644 index 000000000000..a700f9ca48c4 --- /dev/null +++ b/src/controllers/activitypub/index.js @@ -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); + } +}; diff --git a/src/controllers/activitypub/topics.js b/src/controllers/activitypub/topics.js new file mode 100644 index 000000000000..d8ea8dd00ee9 --- /dev/null +++ b/src/controllers/activitypub/topics.js @@ -0,0 +1,102 @@ +'use strict'; + +const nconf = require('nconf'); + +const db = require('../../database'); +const user = require('../../user'); +const topics = require('../../topics'); + +const pagination = require('../../pagination'); +const helpers = require('../helpers'); + +const categories = require('../../categories'); +const privileges = require('../../privileges'); +const translator = require('../../translator'); +const meta = require('../../meta'); + +const controller = module.exports; + +const validSorts = [ + 'recently_replied', 'recently_created', 'most_posts', 'most_votes', 'most_views', +]; + +controller.list = async function (req, res) { + if (!req.uid) { + return helpers.redirect(res, '/recent?cid=-1', false); + } + + const { topicsPerPage } = await user.getSettings(req.uid); + const page = parseInt(req.query.page, 10) || 1; + const start = Math.max(0, (page - 1) * topicsPerPage); + const stop = start + topicsPerPage - 1; + + const sortToSet = { + recently_replied: `cid:-1:tids`, + recently_created: `cid:-1:tids:create`, + most_posts: `cid:-1:tids:posts`, + most_votes: `cid:-1:tids:votes`, + most_views: `cid:-1:tids:views`, + }; + + const [userPrivileges, tagData, userSettings, rssToken] = await Promise.all([ + privileges.categories.get('-1', req.uid), + helpers.getSelectedTag(req.query.tag), + user.getSettings(req.uid), + user.auth.getFeedToken(req.uid), + ]); + const sort = validSorts.includes(req.query.sort) ? req.query.sort : userSettings.categoryTopicSort; + + const sets = [sortToSet[sort], `uid:${req.uid}:inbox`]; + const tids = await db.getSortedSetRevIntersect({ + sets, + start, + stop, + weights: sets.map((s, index) => (index ? 0 : 1)), + }); + + const targetUid = await user.getUidByUserslug(req.query.author); + + const data = await categories.getCategoryById({ + uid: req.uid, + cid: '-1', + start: start, + stop: stop, + sort: sort, + settings: userSettings, + query: req.query, + tag: req.query.tag, + targetUid: targetUid, + }); + data.name = '[[activitypub:world.name]]'; + delete data.children; + + data.topicCount = await db.sortedSetIntersectCard(sets); + data.topics = await topics.getTopicsByTids(tids, { uid: req.uid }); + topics.calculateTopicIndices(data.topics, start); + + data.title = translator.escape(data.name); + data.privileges = userPrivileges; + data.selectedTag = tagData.selectedTag; + data.selectedTags = tagData.selectedTags; + + data.breadcrumbs = helpers.buildBreadcrumbs([{ text: `[[pages:world]]` }]); + data['feeds:disableRSS'] = meta.config['feeds:disableRSS'] || 0; + data['reputation:disabled'] = meta.config['reputation:disabled']; + if (!meta.config['feeds:disableRSS']) { + data.rssFeedUrl = `${nconf.get('url')}/category/${data.cid}.rss`; + if (req.loggedIn) { + data.rssFeedUrl += `?uid=${req.uid}&token=${rssToken}`; + } + } + + const pageCount = Math.max(1, Math.ceil(data.topicCount / topicsPerPage)); + data.pagination = pagination.create(page, pageCount, req.query); + helpers.addLinkTags({ + url: 'world', + res: req.res, + tags: data.pagination.rel, + page: page, + }); + + res.render('world', data); +}; diff --git a/src/controllers/admin/categories.js b/src/controllers/admin/categories.js index 75e85e09838d..99bc2cffd208 100644 --- a/src/controllers/admin/categories.js +++ b/src/controllers/admin/categories.js @@ -2,6 +2,7 @@ const _ = require('lodash'); const nconf = require('nconf'); +const db = require('../../database'); const categories = require('../../categories'); const analytics = require('../../analytics'); const plugins = require('../../plugins'); @@ -145,3 +146,26 @@ categoriesController.getAnalytics = async function (req, res) { selectedCategory: selectedData.selectedCategory, }); }; + +categoriesController.getFederation = async function (req, res) { + const cid = req.params.category_id; + const [_following, pending, name, { selectedCategory }] = await Promise.all([ + db.getSortedSetMembers(`cid:${cid}:following`), + db.getSortedSetMembers(`followRequests:cid.${cid}`), + categories.getCategoryField(cid, 'name'), + helpers.getSelectedCategory(cid), + ]); + + const following = [..._following, ...pending].map(entry => ({ + id: entry, + approved: !pending.includes(entry), + })); + + res.render('admin/manage/category-federation', { + cid: cid, + enabled: meta.config.activitypubEnabled, + name, + selectedCategory, + following, + }); +}; diff --git a/src/controllers/admin/privileges.js b/src/controllers/admin/privileges.js index 28833b5562e7..8a33fb462506 100644 --- a/src/controllers/admin/privileges.js +++ b/src/controllers/admin/privileges.js @@ -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 = [{ diff --git a/src/controllers/category.js b/src/controllers/category.js index 487ea21ccef7..fb2556dd783a 100644 --- a/src/controllers/category.js +++ b/src/controllers/category.js @@ -26,6 +26,9 @@ const validSorts = [ categoryController.get = async function (req, res, next) { const cid = req.params.category_id; + if (cid === '-1') { + return helpers.redirect(res, `${res.locals.isAPI ? '/api' : ''}/world?${qs.stringify(req.query)}`); + } let currentPage = parseInt(req.query.page, 10) || 1; let topicIndex = utils.isNumber(req.params.topic_index) ? parseInt(req.params.topic_index, 10) - 1 : 0; @@ -155,6 +158,11 @@ categoryController.get = async function (req, res, next) { analytics.increment([`pageviews:byCid:${categoryData.cid}`]); + if (meta.config.activitypubEnabled) { + // Include link header for richer parsing + res.set('Link', `<${nconf.get('url')}/actegory/${cid}>; rel="alternate"; type="application/activity+json"`); + } + res.render('category', categoryData); }; @@ -226,4 +234,12 @@ function addTags(categoryData, res, currentPage) { href: categoryData.rssFeedUrl, }); } + + if (meta.config.activitypubEnabled) { + res.locals.linkTags.push({ + rel: 'alternate', + type: 'application/activity+json', + href: `${nconf.get('url')}/actegory/${categoryData.cid}`, + }); + } } diff --git a/src/controllers/index.js b/src/controllers/index.js index 253df71a67d3..51a1cf87bfcf 100644 --- a/src/controllers/index.js +++ b/src/controllers/index.js @@ -12,6 +12,8 @@ const helpers = require('./helpers'); const Controllers = module.exports; Controllers.ping = require('./ping'); +Controllers['well-known'] = require('./well-known'); +Controllers.activitypub = require('./activitypub'); Controllers.home = require('./home'); Controllers.topics = require('./topics'); Controllers.posts = require('./posts'); diff --git a/src/controllers/posts.js b/src/controllers/posts.js index 7865ba0af76a..2b08558a9a71 100644 --- a/src/controllers/posts.js +++ b/src/controllers/posts.js @@ -1,19 +1,32 @@ 'use strict'; +const nconf = require('nconf'); const querystring = require('querystring'); +const meta = require('../meta'); const posts = require('../posts'); const privileges = require('../privileges'); +const activitypub = require('../activitypub'); +const utils = require('../utils'); + const helpers = require('./helpers'); const postsController = module.exports; postsController.redirectToPost = async function (req, res, next) { - const pid = parseInt(req.params.pid, 10); + const pid = utils.isNumber(req.params.pid) ? parseInt(req.params.pid, 10) : req.params.pid; if (!pid) { return next(); } + // Kickstart note assertion if applicable + if (!utils.isNumber(pid) && req.uid) { + const exists = await posts.exists(pid); + if (!exists) { + await activitypub.notes.assert(req.uid, pid); + } + } + const [canRead, path] = await Promise.all([ privileges.posts.can('topics:read', pid, req.uid), posts.generatePostPath(pid, req.uid), @@ -25,6 +38,11 @@ postsController.redirectToPost = async function (req, res, next) { return helpers.notAllowed(req, res); } + if (meta.config.activitypubEnabled) { + // Include link header for richer parsing + res.set('Link', `<${nconf.get('url')}/post/${req.params.pid}>; rel="alternate"; type="application/activity+json"`); + } + const qs = querystring.stringify(req.query); helpers.redirect(res, qs ? `${path}?${qs}` : path, true); }; diff --git a/src/controllers/recent.js b/src/controllers/recent.js index 5699fee1b75f..d4d577cabd5c 100644 --- a/src/controllers/recent.js +++ b/src/controllers/recent.js @@ -65,7 +65,7 @@ recentController.getData = async function (req, url, sort) { data.title = meta.config.homePageTitle || '[[pages:home]]'; } else { data.title = `[[pages:${url}]]`; - data.breadcrumbs = helpers.buildBreadcrumbs([{ text: `[[${url}:title]]` }]); + data.breadcrumbs = helpers.buildBreadcrumbs([{ text: `[[activitypub:world-title]]` }]); } const query = { ...req.query }; diff --git a/src/controllers/tags.js b/src/controllers/tags.js index 392ff9201e56..4b8385b17789 100644 --- a/src/controllers/tags.js +++ b/src/controllers/tags.js @@ -25,7 +25,7 @@ tagsController.getTag = async function (req, res) { breadcrumbs: helpers.buildBreadcrumbs([{ text: '[[tags:tags]]', url: '/tags' }, { text: tag }]), title: `[[pages:tag, ${tag}]]`, }; - const [settings, cids, categoryData, canPost, isPrivileged, rssToken, isFollowing] = await Promise.all([ + let [settings, cids, categoryData, canPost, isPrivileged, rssToken, isFollowing] = await Promise.all([ user.getSettings(req.uid), cid || categories.getCidsByPrivilege('categories:cid', req.uid, 'topics:read'), helpers.getSelectedCategory(cid), @@ -34,6 +34,14 @@ tagsController.getTag = async function (req, res) { user.auth.getFeedToken(req.uid), topics.isFollowingTag(req.params.tag, req.uid), ]); + + // Explicitly exclude cid -1 if cid not specified + if (!cid) { + cids = new Set(cids); + cids.delete(-1); + cids = Array.from(cids); + } + const start = Math.max(0, (page - 1) * settings.topicsPerPage); const stop = start + settings.topicsPerPage - 1; @@ -83,7 +91,8 @@ tagsController.getTag = async function (req, res) { }; tagsController.getTags = async function (req, res) { - const cids = await categories.getCidsByPrivilege('categories:cid', req.uid, 'topics:read'); + let cids = await categories.getCidsByPrivilege('categories:cid', req.uid, 'topics:read'); + cids = cids.filter(cid => cid !== -1); const [canSearch, tags] = await Promise.all([ privileges.global.can('search:tags', req.uid), topics.getCategoryTagsData(cids, 0, 99), diff --git a/src/controllers/topics.js b/src/controllers/topics.js index 9ea025e0387e..54535f70c9e6 100644 --- a/src/controllers/topics.js +++ b/src/controllers/topics.js @@ -26,7 +26,7 @@ topicsController.get = async function getTopic(req, res, next) { const tid = req.params.topic_id; if ( (req.params.post_index && !utils.isNumber(req.params.post_index) && req.params.post_index !== 'unread') || - !utils.isNumber(tid) + (!utils.isNumber(tid) && !validator.isUUID(tid)) ) { return next(); } @@ -122,7 +122,7 @@ topicsController.get = async function getTopic(req, res, next) { buildBreadcrumbs(topicData), addOldCategory(topicData, userPrivileges), addTags(topicData, req, res, currentPage), - incrementViewCount(req, tid), + topics.increaseViewCount(req, tid), markAsRead(req, tid), analytics.increment([`pageviews:byCid:${topicData.category.cid}`]), ]); @@ -133,6 +133,14 @@ topicsController.get = async function getTopic(req, res, next) { rel.href = `${url}/topic/${topicData.slug}${rel.href}`; res.locals.linkTags.push(rel); }); + + if (meta.config.activitypubEnabled) { + // Include link header for richer parsing + const pid = await topics.getPidByIndex(tid, postIndex); + const href = utils.isNumber(pid) ? `${nconf.get('url')}/post/${pid}` : pid; + res.set('Link', `<${href}>; rel="alternate"; type="application/activity+json"`); + } + res.render('topic', topicData); }; @@ -160,19 +168,6 @@ function calculateStartStop(page, postIndex, settings) { return { start: Math.max(0, start), stop: Math.max(0, stop) }; } -async function incrementViewCount(req, tid) { - const allow = req.uid > 0 || (meta.config.guestsIncrementTopicViews && req.uid === 0); - if (allow) { - req.session.tids_viewed = req.session.tids_viewed || {}; - const now = Date.now(); - const interval = meta.config.incrementTopicViewsInterval * 60000; - if (!req.session.tids_viewed[tid] || req.session.tids_viewed[tid] < now - interval) { - await topics.increaseViewCount(tid); - req.session.tids_viewed[tid] = now; - } - } -} - async function markAsRead(req, tid) { if (req.loggedIn) { const markedRead = await topics.markAsRead([tid], req.uid); @@ -297,6 +292,16 @@ async function addTags(topicData, req, res, currentPage) { href: `${url}/user/${postAtIndex.user.userslug}`, }); } + + if (meta.config.activitypubEnabled) { + const pid = await topics.getPidByIndex(topicData.tid, topicData.postIndex); + + res.locals.linkTags.push({ + rel: 'alternate', + type: 'application/activity+json', + href: utils.isNumber(pid) ? `${nconf.get('url')}/post/${pid}` : pid, + }); + } } async function addOGImageTags(res, topicData, postAtIndex) { diff --git a/src/controllers/well-known.js b/src/controllers/well-known.js new file mode 100644 index 000000000000..93a083917691 --- /dev/null +++ b/src/controllers/well-known.js @@ -0,0 +1,112 @@ +'use strict'; + +const nconf = require('nconf'); + +const meta = require('../meta'); +const user = require('../user'); +const categories = require('../categories'); +const privileges = require('../privileges'); + +const Controller = module.exports; + +Controller.webfinger = async (req, res) => { + const { resource } = req.query; + const { host, hostname } = nconf.get('url_parsed'); + + if (!resource || !resource.startsWith('acct:') || !resource.endsWith(host)) { + return res.sendStatus(400); + } + + // Get the slug + const slug = resource.slice(5, resource.length - (host.length + 1)); + const [uid, cid] = await Promise.all([ + user.getUidByUserslug(slug), + categories.getCidByHandle(slug), + ]); + let response = { + subject: `acct:${slug}@${host}`, + }; + + try { + if (slug === hostname) { + response = application(response); + } else if (uid) { + response = await profile(req.uid, uid, response); + } else if (cid) { + response = await category(req.uid, cid, response); + } else { + return res.sendStatus(404); + } + + res.status(200).json(response); + } catch (e) { + res.sendStatus(400); + } +}; + +function application(response) { + response.aliases = [nconf.get('url')]; + response.links = []; + + if (meta.config.activitypubEnabled) { + response.links.push({ + rel: 'self', + type: 'application/activity+json', + href: `${nconf.get('url')}/actor`, // actor + }); + } + + return response; +} + +async function profile(callerUid, uid, response) { + const canView = await privileges.global.can('view:users', callerUid); + if (!canView) { + throw new Error('[[error:no-privileges]]'); + } + const slug = await user.getUserField(uid, 'userslug'); + + response.aliases = [ + `${nconf.get('url')}/uid/${uid}`, + `${nconf.get('url')}/user/${slug}`, + ]; + + response.links = [ + { + rel: 'http://webfinger.net/rel/profile-page', + type: 'text/html', + href: `${nconf.get('url')}/user/${slug}`, + }, + ]; + + if (meta.config.activitypubEnabled) { + response.links.push({ + rel: 'self', + type: 'application/activity+json', + href: `${nconf.get('url')}/uid/${uid}`, // actor + }); + } + + return response; +} + +async function category(callerUid, cid, response) { + const canFind = await privileges.categories.can('find', cid, callerUid); + if (!canFind) { + throw new Error('[[error:no-privileges]]'); + } + const slug = await categories.getCategoryField(cid, 'slug'); + + response.aliases = [`${nconf.get('url')}/category/${slug}`]; + response.links = []; + + if (meta.config.activitypubEnabled) { + response.links.push({ + rel: 'self', + type: 'application/activity+json', + href: `${nconf.get('url')}/category/${cid}`, // actor + }); + } + + return response; +} diff --git a/src/controllers/write/categories.js b/src/controllers/write/categories.js index bb4ec8409060..8a2a0027133a 100644 --- a/src/controllers/write/categories.js +++ b/src/controllers/write/categories.js @@ -105,3 +105,37 @@ Categories.setModerator = async (req, res) => { const privilegeSet = await api.categories.getPrivileges(req, { cid: req.params.cid }); helpers.formatApiResponse(200, res, privilegeSet); }; + +Categories.follow = async (req, res, next) => { + const { actor } = req.body; + const id = parseInt(req.params.cid, 10); + + if (!id) { // disallow cid 0 + return next(); + } + + await api.activitypub.follow(req, { + type: 'cid', + id, + actor, + }); + + helpers.formatApiResponse(200, res, {}); +}; + +Categories.unfollow = async (req, res, next) => { + const { actor } = req.body; + const id = parseInt(req.params.cid, 10); + + if (!id) { // disallow cid 0 + return next(); + } + + await api.activitypub.unfollow(req, { + type: 'cid', + id, + actor, + }); + + helpers.formatApiResponse(200, res, {}); +}; diff --git a/src/controllers/write/flags.js b/src/controllers/write/flags.js index 4e3ac376a7cf..786159ea6239 100644 --- a/src/controllers/write/flags.js +++ b/src/controllers/write/flags.js @@ -7,8 +7,8 @@ const helpers = require('../helpers'); const Flags = module.exports; Flags.create = async (req, res) => { - const { type, id, reason } = req.body; - const flagObj = await api.flags.create(req, { type, id, reason }); + const { type, id, reason, notifyRemote } = req.body; + const flagObj = await api.flags.create(req, { type, id, reason, notifyRemote }); helpers.formatApiResponse(200, res, await user.isPrivileged(req.uid) ? flagObj : undefined); }; diff --git a/src/controllers/write/users.js b/src/controllers/write/users.js index 715be0f48c63..b884ef93fb87 100644 --- a/src/controllers/write/users.js +++ b/src/controllers/write/users.js @@ -92,12 +92,31 @@ Users.changePassword = async (req, res) => { }; Users.follow = async (req, res) => { - await api.users.follow(req, req.params); + const remote = String(req.params.uid).includes('@'); + if (remote) { + await api.activitypub.follow(req, { + type: 'uid', + id: req.uid, + actor: req.params.uid, + }); + } else { + await api.users.follow(req, req.params); + } + helpers.formatApiResponse(200, res); }; Users.unfollow = async (req, res) => { - await api.users.unfollow(req, req.params); + const remote = String(req.params.uid).includes('@'); + if (remote) { + await api.activitypub.unfollow(req, { + type: 'uid', + id: req.uid, + actor: req.params.uid, + }); + } else { + await api.users.unfollow(req, req.params); + } helpers.formatApiResponse(200, res); }; diff --git a/src/database/index.js b/src/database/index.js index 2366ae3671bb..a7b851869bd7 100644 --- a/src/database/index.js +++ b/src/database/index.js @@ -11,11 +11,14 @@ if (!databaseName) { } const primaryDB = require(`./${databaseName}`); +const utils = require('../utils'); primaryDB.parseIntFields = function (data, intFields, requestedFields) { intFields.forEach((field) => { if (!requestedFields || !requestedFields.length || requestedFields.includes(field)) { - data[field] = parseInt(data[field], 10) || 0; + data[field] = utils.isNumber(data[field]) ? + parseInt(data[field], 10) : + data[field] || 0; } }); }; diff --git a/src/flags.js b/src/flags.js index 00bce1d9bd6f..5d14319f76f4 100644 --- a/src/flags.js +++ b/src/flags.js @@ -4,6 +4,8 @@ const _ = require('lodash'); const winston = require('winston'); const validator = require('validator'); +const activitypub = require('./activitypub'); +const activitypubApi = require('./api/activitypub'); const db = require('./database'); const user = require('./user'); const groups = require('./groups'); @@ -352,7 +354,8 @@ Flags.getFlagIdByTarget = async function (type, id) { throw new Error('[[error:invalid-data]]'); } - return await method(id, 'flagId'); + const flagId = await method(id, 'flagId'); + return utils.isNumber(flagId) ? parseInt(flagId, 10) : flagId; }; async function modifyNotes(notes) { @@ -389,7 +392,7 @@ Flags.deleteNote = async function (flagId, datetime) { await db.sortedSetRemove(`flag:${flagId}:notes`, note[0]); }; -Flags.create = async function (type, id, uid, reason, timestamp, forceFlag = false) { +Flags.create = async function (type, id, uid, reason, timestamp, forceFlag = false, notifyRemote = false) { let doHistoryAppend = false; if (!timestamp) { timestamp = Date.now(); @@ -416,7 +419,7 @@ Flags.create = async function (type, id, uid, reason, timestamp, forceFlag = fal if (targetFlagged) { const flagId = await Flags.getFlagIdByTarget(type, id); await Promise.all([ - Flags.addReport(flagId, type, id, uid, reason, timestamp), + Flags.addReport(flagId, type, id, uid, reason, timestamp, targetUid, notifyRemote), Flags.update(flagId, uid, { state: 'open', report: 'added', @@ -437,7 +440,7 @@ Flags.create = async function (type, id, uid, reason, timestamp, forceFlag = fal targetUid: targetUid, datetime: timestamp, }), - Flags.addReport(flagId, type, id, uid, reason, timestamp), + Flags.addReport(flagId, type, id, uid, reason, timestamp, targetUid, notifyRemote), db.sortedSetAdd('flags:datetime', timestamp, flagId), // by time, the default db.sortedSetAdd(`flags:byType:${type}`, timestamp, flagId), // by flag type db.sortedSetIncrBy('flags:byTarget', 1, [type, id].join(':')), // by flag target (score is count) @@ -474,6 +477,11 @@ Flags.create = async function (type, id, uid, reason, timestamp, forceFlag = fal const flagObj = await Flags.get(flagId); + if (notifyRemote && activitypub.helpers.isUri(id)) { + const caller = await user.getUserData(uid); + activitypubApi.flag(caller, { ...flagObj, reason }); + } + plugins.hooks.fire('action:flags.create', { flag: flagObj }); return flagObj; }; @@ -521,6 +529,13 @@ Flags.purge = async function (flagIds) { 'flags:byTarget', flagData.map(flagObj => [flagObj.type, flagObj.targetId].join(':')) ), + flagData.flatMap( + async (flagObj, i) => allReporterUids[i].map(async (uid) => { + if (await db.isSortedSetMember(`flag:${flagObj.flagId}:remote`, uid)) { + await activitypubApi.undo.flag({ uid }, flagObj); + } + }) + ), ]); }; @@ -546,7 +561,7 @@ Flags.getReports = async function (flagId) { }; // Not meant to be called directly, call Flags.create() instead. -Flags.addReport = async function (flagId, type, id, uid, reason, timestamp) { +Flags.addReport = async function (flagId, type, id, uid, reason, timestamp, targetUid, notifyRemote) { await db.sortedSetAddBulk([ [`flags:byReporter:${uid}`, timestamp, flagId], [`flag:${flagId}:reports`, timestamp, [uid, reason].join(';')], @@ -554,6 +569,10 @@ Flags.addReport = async function (flagId, type, id, uid, reason, timestamp) { ['flags:hash', flagId, [type, id, uid].join(':')], ]); + if (notifyRemote && activitypub.helpers.isUri(id)) { + await activitypubApi.flag({ uid }, { flagId, type, targetId: id, targetUid, uid, reason, timestamp }); + } + plugins.hooks.fire('action:flags.addReport', { flagId, type, id, uid, reason, timestamp }); }; @@ -579,6 +598,11 @@ Flags.rescindReport = async (type, id, uid) => { throw new Error('[[error:cant-locate-flag-report]]'); } + if (await db.isSortedSetMember(`flag:${flagId}:remote`, uid)) { + const flag = await Flags.get(flagId); + await activitypubApi.undo.flag({ uid }, flag); + } + await db.sortedSetRemoveBulk([ [`flags:byReporter:${uid}`, flagId], [`flag:${flagId}:reports`, [uid, reason].join(';')], @@ -681,6 +705,14 @@ Flags.targetExists = async function (type, id) { if (type === 'post') { return await posts.exists(id); } else if (type === 'user') { + if (activitypub.helpers.isUri(id)) { + try { + const actor = await activitypub.get('uid', 0, id); + return !!actor; + } catch (_) { + return false; + } + } return await user.exists(id); } throw new Error('[[error:invalid-data]]'); diff --git a/src/groups/create.js b/src/groups/create.js index 51720380520a..aea069c829d6 100644 --- a/src/groups/create.js +++ b/src/groups/create.js @@ -19,7 +19,7 @@ module.exports = function (Groups) { Groups.validateGroupName(data.name); const [exists, privGroupExists] = await Promise.all([ - meta.userOrGroupExists(data.name), + meta.slugTaken(data.name), privilegeGroupExists(data.name), ]); if (exists || privGroupExists) { diff --git a/src/groups/index.js b/src/groups/index.js index 8aef1a7b51c7..c6a63181aad3 100644 --- a/src/groups/index.js +++ b/src/groups/index.js @@ -25,7 +25,7 @@ require('./cache')(Groups); Groups.BANNED_USERS = 'banned-users'; -Groups.ephemeralGroups = ['guests', 'spiders']; +Groups.ephemeralGroups = ['guests', 'spiders', 'fediverse']; Groups.systemGroups = [ 'registered-users', diff --git a/src/install.js b/src/install.js index 89b40d7b3983..53a6941c5419 100644 --- a/src/install.js +++ b/src/install.js @@ -436,6 +436,37 @@ async function giveGlobalPrivileges() { ]), 'Global Moderators'); await privileges.global.give(['groups:view:users', 'groups:view:tags', 'groups:view:groups'], 'guests'); await privileges.global.give(['groups:view:users', 'groups:view:tags', 'groups:view:groups'], 'spiders'); + await privileges.global.give(['groups:view:users'], 'fediverse'); +} + +async function giveWorldPrivileges() { + // should match privilege assignment logic in src/categories/create.js EXCEPT commented one liner below + const privileges = require('./privileges'); + const defaultPrivileges = [ + 'groups:find', + 'groups:read', + 'groups:topics:read', + 'groups:topics:create', + 'groups:topics:reply', + 'groups:topics:tag', + 'groups:posts:edit', + 'groups:posts:history', + 'groups:posts:delete', + 'groups:posts:upvote', + 'groups:posts:downvote', + 'groups:topics:delete', + ]; + const modPrivileges = defaultPrivileges.concat([ + 'groups:topics:schedule', + 'groups:posts:view_deleted', + 'groups:purge', + ]); + const guestPrivileges = ['groups:find', 'groups:read', 'groups:topics:read']; + + await privileges.categories.give(defaultPrivileges, -1, ['registered-users']); + await privileges.categories.give(defaultPrivileges.slice(2), -1, ['fediverse']); // different priv set for fediverse + await privileges.categories.give(modPrivileges, -1, ['administrators', 'Global Moderators']); + await privileges.categories.give(guestPrivileges, -1, ['guests', 'spiders']); } async function createCategories() { @@ -588,6 +619,7 @@ install.setup = async function () { const adminInfo = await createAdministrator(); await createGlobalModeratorsGroup(); await giveGlobalPrivileges(); + await giveWorldPrivileges(); await createMenuItems(); await createWelcomePost(); await enableDefaultPlugins(); @@ -630,3 +662,5 @@ install.save = async function (server_conf) { file: serverConfigPath, }); }; + +install.giveWorldPrivileges = giveWorldPrivileges; // exported for upgrade script and test runner diff --git a/src/messaging/uploads.js b/src/messaging/uploads.js new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/meta/index.js b/src/meta/index.js index 487c53df6053..04f1851d349b 100644 --- a/src/meta/index.js +++ b/src/meta/index.js @@ -24,21 +24,22 @@ Meta.templates = require('./templates'); Meta.blacklist = require('./blacklist'); Meta.languages = require('./languages'); - -/* Assorted */ -Meta.userOrGroupExists = async function (slug) { +Meta.slugTaken = async function (slug) { if (!slug) { throw new Error('[[error:invalid-data]]'); } - const user = require('../user'); - const groups = require('../groups'); + + const [user, groups, categories] = [require('../user'), require('../groups'), require('../categories')]; slug = slugify(slug); - const [userExists, groupExists] = await Promise.all([ + + const exists = await Promise.all([ user.existsBySlug(slug), groups.existsBySlug(slug), + categories.existsByHandle(slug), ]); - return userExists || groupExists; + return exists.some(Boolean); }; +Meta.userOrGroupExists = Meta.slugTaken; // backwards compatiblity if (nconf.get('isPrimary')) { pubsub.on('meta:restart', (data) => { diff --git a/src/middleware/activitypub.js b/src/middleware/activitypub.js new file mode 100644 index 000000000000..aa8c0c9e260e --- /dev/null +++ b/src/middleware/activitypub.js @@ -0,0 +1,118 @@ +'use strict'; + +const winston = require('winston'); + +const db = require('../database'); +const meta = require('../meta'); +const activitypub = require('../activitypub'); + +const middleware = module.exports; + +middleware.enabled = async (req, res, next) => next(!meta.config.activitypubEnabled ? 'route' : undefined); + +middleware.assertS2S = async function (req, res, next) { + // For whatever reason, express accepts does not recognize "profile" as a valid differentiator + // Therefore, manual header parsing is used here. + const { accept, 'content-type': contentType } = req.headers; + if (!(accept || contentType)) { + return next('route'); + } + + const pass = (accept && accept.split(',').some((value) => { + const parts = value.split(';').map(v => v.trim()); + return activitypub._constants.acceptableTypes.includes(value || parts[0]); + })) || (contentType && activitypub._constants.acceptableTypes.includes(contentType)); + + if (!pass) { + return next('route'); + } + + next(); +}; + +middleware.validate = async function (req, res, next) { + winston.verbose('[middleware/activitypub] Validating incoming payload...'); + + // Sanity-check payload schema + const required = ['id', 'type', 'actor', 'object']; + if (!required.every(prop => req.body.hasOwnProperty(prop))) { + winston.verbose('[middleware/activitypub] Request body missing required properties.'); + return res.sendStatus(400); + } + winston.verbose('[middleware/activitypub] Request body check passed.'); + + // History check + const seen = await db.isSortedSetMember('activities:datetime', req.body.id); + if (seen) { + winston.verbose(`[middleware/activitypub] Activity already seen, ignoring (${req.body.id}).`); + return res.sendStatus(200); + } + + // Checks the validity of the incoming payload against the sender and rejects on failure + const verified = await activitypub.verify(req); + if (!verified) { + winston.verbose('[middleware/activitypub] HTTP signature verification failed.'); + return res.sendStatus(400); + } + winston.verbose('[middleware/activitypub] HTTP signature verification passed.'); + + let { actor, object } = req.body; + + // Actor normalization + if (typeof actor === 'object' && actor.hasOwnProperty('id')) { + actor = actor.id; + req.body.actor = actor; + } + if (Array.isArray(actor)) { + actor = actor.map(a => (typeof a === 'string' ? a : a.id)); + req.body.actor = actor; + } + + // Origin checking + if (typeof object !== 'string' && object.hasOwnProperty('id')) { + const actorHostnames = Array.isArray(actor) ? actor.map(a => new URL(a).hostname) : [new URL(actor).hostname]; + const objectHostname = new URL(object.id).hostname; + // require that all actors have the same hostname as the object for now + if (!actorHostnames.every(actorHostname => actorHostname === objectHostname)) { + winston.verbose('[middleware/activitypub] Origin check failed, stripping object down to id.'); + req.body.object = [object.id]; + } + winston.verbose('[middleware/activitypub] Origin check passed.'); + } + + // Cross-check key ownership against received actor + await activitypub.actors.assert(actor); + const compare = await db.getObjectField(`userRemote:${actor}:keys`, 'id'); + const { signature } = req.headers; + const keyId = new Map(signature.split(',').filter(Boolean).map((v) => { + const index = v.indexOf('='); + return [v.substring(0, index), v.slice(index + 1)]; + })).get('keyId'); + if (`"${compare}"` !== keyId) { + winston.verbose('[middleware/activitypub] Key ownership cross-check failed.'); + return res.sendStatus(403); + } + winston.verbose('[middleware/activitypub] Key ownership cross-check passed.'); + + next(); +}; + +middleware.resolveObjects = async function (req, res, next) { + const { type, object } = req.body; + if (type !== 'Delete' && (typeof object === 'string' || (Array.isArray(object) && object.every(o => typeof o === 'string')))) { + winston.verbose('[middleware/activitypub] Resolving object(s)...'); + try { + req.body.object = await activitypub.helpers.resolveObjects(object); + winston.verbose('[middleware/activitypub] Object(s) successfully resolved.'); + } catch (e) { + winston.verbose('[middleware/activitypub] Failed to resolve object(s).'); + return res.sendStatus(424); + } + } + next(); +}; + +middleware.configureResponse = async function (req, res, next) { + res.header('Content-Type', 'application/activity+json'); + next(); +}; diff --git a/src/middleware/assert.js b/src/middleware/assert.js index 6c0f5ef72fb6..67a63b034427 100644 --- a/src/middleware/assert.js +++ b/src/middleware/assert.js @@ -17,6 +17,8 @@ const posts = require('../posts'); const messaging = require('../messaging'); const flags = require('../flags'); const slugify = require('../slugify'); +const utils = require('../utils'); +const activitypub = require('../activitypub'); const helpers = require('./helpers'); const controllerHelpers = require('../controllers/helpers'); @@ -24,11 +26,16 @@ const controllerHelpers = require('../controllers/helpers'); const Assert = module.exports; Assert.user = helpers.try(async (req, res, next) => { - if (!await user.exists(req.params.uid)) { - return controllerHelpers.formatApiResponse(404, res, new Error('[[error:no-user]]')); + const uid = req.params.uid || res.locals.uid; + + if ( + (utils.isNumber(uid) && await user.exists(uid)) || + (uid.indexOf('@') !== -1 && await activitypub.helpers.query(uid)) + ) { + return next(); } - next(); + controllerHelpers.formatApiResponse(404, res, new Error('[[error:no-user]]')); }); Assert.group = helpers.try(async (req, res, next) => { diff --git a/src/middleware/index.js b/src/middleware/index.js index 80a5f568c6c4..4c6300895e86 100644 --- a/src/middleware/index.js +++ b/src/middleware/index.js @@ -68,6 +68,7 @@ middleware.uploads = require('./uploads'); require('./headers')(middleware); require('./expose')(middleware); middleware.assert = require('./assert'); +middleware.activitypub = require('./activitypub'); middleware.stripLeadingSlashes = function stripLeadingSlashes(req, res, next) { const target = req.originalUrl.replace(relative_path, ''); @@ -172,7 +173,15 @@ async function expose(exposedField, method, field, req, res, next) { if (!req.params.hasOwnProperty(field)) { return next(); } - const value = await method(String(req.params[field]).toLowerCase()); + const param = String(req.params[field]).toLowerCase(); + + // potential hostname — ActivityPub + if (param.indexOf('@') !== -1) { + res.locals[exposedField] = -2; + return next(); + } + + const value = await method(param); if (!value) { next('route'); return; diff --git a/src/notifications.js b/src/notifications.js index fdb9998248a8..5b5db836ac66 100644 --- a/src/notifications.js +++ b/src/notifications.js @@ -88,7 +88,9 @@ Notifications.getMultiple = async function (nids) { if (notification) { intFields.forEach((field) => { if (notification.hasOwnProperty(field)) { - notification[field] = parseInt(notification[field], 10) || 0; + notification[field] = utils.isNumber(notification[field]) ? + parseInt(notification[field], 10) || 0 : + notification[field]; } }); if (notification.path && !notification.path.startsWith('http')) { @@ -107,7 +109,7 @@ Notifications.getMultiple = async function (nids) { notification.bodyShort = notification.bodyShort.replace(/([\s\S]*?),[\s\S]*?,([\s\S]*?)/, '$1, [[global:guest]], $2'); } } else if (notification.image === 'brand:logo' || !notification.image) { - notification.image = meta.config['brand:logo'] || `${nconf.get('relative_path')}/logo.png`; + notification.image = meta.config['brand:logo'] || `${nconf.get('relative_path')}/assets/logo.png`; } } }); @@ -410,6 +412,7 @@ Notifications.merge = async function (notifications) { 'notifications:user-posted-in-public-room', 'new-register', 'post-queue', + 'notifications:activitypub.announce', ]; notifications = mergeIds.reduce((notifications, mergeId) => { @@ -474,7 +477,8 @@ Notifications.merge = async function (notifications) { case 'notifications:user-started-following-you': case 'notifications:user-posted-to': case 'notifications:user-flagged-post-in': - case 'notifications:user-flagged-user': { + case 'notifications:user-flagged-user': + case 'notifications:activitypub.announce': { const usernames = _.uniq(set.map(notifObj => notifObj && notifObj.user && notifObj.user.username)); const numUsers = usernames.length; diff --git a/src/posts/attachments.js b/src/posts/attachments.js new file mode 100644 index 000000000000..8a5ab822ee8e --- /dev/null +++ b/src/posts/attachments.js @@ -0,0 +1,62 @@ +'use strict'; + +const winston = require('winston'); +const crypto = require('crypto'); + +const db = require('../database'); + +const Attachments = module.exports; + +Attachments.get = async (pid) => { + const hashes = await db.getSortedSetMembers(`post:${pid}:attachments`); + const keys = hashes.map(hash => `attachment:${hash}`); + const attachments = (await db.getObjects(keys)).filter(Boolean); + + return attachments; +}; + +Attachments.update = async (pid, attachments) => { + if (!attachments) { + return; + } + + const bulkOps = { + hash: [], + zset: { + score: [], + value: [], + }, + }; + + attachments.filter(Boolean).forEach(({ _type, mediaType, url, name, width, height }, idx) => { + if (!url) { // only required property + return; + } + + const hash = crypto.createHash('sha256').update(url).digest('hex'); + const key = `attachment:${hash}`; + + if (_type) { + _type = 'attachment'; + } + + bulkOps.hash.push([key, { _type, mediaType, url, name, width, height }]); + bulkOps.zset.score.push(idx); + bulkOps.zset.value.push(hash); + }); + + await Promise.all([ + db.setObjectBulk(bulkOps.hash), + db.sortedSetAdd(`post:${pid}:attachments`, bulkOps.zset.score, bulkOps.zset.value), + ]); +}; + +Attachments.empty = async (pids) => { + winston.verbose(`[posts/attachments] Emptying attachments for ids ${pids.join(', ')}.`); + const zsets = pids.map(pid => `post:${pid}:attachments`); + const hashes = await db.getSortedSetsMembers(zsets); + let keys = hashes.reduce((memo, hashes) => new Set([...memo, ...hashes]), new Set()); + keys = Array.from(keys).map(hash => `attachment:${hash}`); + + await db.deleteAll(keys.concat(zsets)); +}; diff --git a/src/posts/category.js b/src/posts/category.js index d5f4874cc179..b87342a89f77 100644 --- a/src/posts/category.js +++ b/src/posts/category.js @@ -6,10 +6,15 @@ const _ = require('lodash'); const db = require('../database'); const topics = require('../topics'); +const activitypub = require('../activitypub'); module.exports = function (Posts) { Posts.getCidByPid = async function (pid) { const tid = await Posts.getPostField(pid, 'tid'); + if (!tid && activitypub.helpers.isUri(pid)) { + return -1; // fediverse pseudo-category + } + return await topics.getTopicField(tid, 'cid'); }; diff --git a/src/posts/create.js b/src/posts/create.js index d541564c2ec1..2c235602661c 100644 --- a/src/posts/create.js +++ b/src/posts/create.js @@ -1,7 +1,5 @@ 'use strict'; -const _ = require('lodash'); - const meta = require('../meta'); const db = require('../database'); const plugins = require('../plugins'); @@ -10,12 +8,12 @@ const topics = require('../topics'); const categories = require('../categories'); const groups = require('../groups'); const privileges = require('../privileges'); +const utils = require('../utils'); module.exports = function (Posts) { Posts.create = async function (data) { // This is an internal method, consider using Topics.reply instead - const { uid } = data; - const { tid } = data; + const { uid, tid, _activitypub } = data; const content = data.content.toString(); const timestamp = data.timestamp || Date.now(); const isMain = data.isMain || false; @@ -28,7 +26,7 @@ module.exports = function (Posts) { await checkToPid(data.toPid, uid); } - const pid = await db.incrObjectField('global', 'nextPid'); + const pid = data.pid || await db.incrObjectField('global', 'nextPid'); let postData = { pid: pid, uid: uid, @@ -46,9 +44,11 @@ module.exports = function (Posts) { if (data.handle && !parseInt(uid, 10)) { postData.handle = data.handle; } + if (_activitypub && _activitypub.url) { + postData.url = _activitypub.url; + } - let result = await plugins.hooks.fire('filter:post.create', { post: postData, data: data }); - postData = result.post; + ({ post: postData } = await plugins.hooks.fire('filter:post.create', { post: postData, data: data })); await db.setObject(`post:${postData.pid}`, postData); const topicData = await topics.getTopicFields(tid, ['cid', 'pinned']); @@ -56,7 +56,7 @@ module.exports = function (Posts) { await Promise.all([ db.sortedSetAdd('posts:pid', timestamp, postData.pid), - db.incrObjectField('global', 'postCount'), + utils.isNumber(pid) ? db.incrObjectField('global', 'postCount') : null, user.onNewPostMade(postData), topics.onNewPostMade(postData), categories.onNewPostMade(topicData.cid, topicData.pinned, postData), @@ -65,9 +65,9 @@ module.exports = function (Posts) { Posts.uploads.sync(postData.pid), ]); - result = await plugins.hooks.fire('filter:post.get', { post: postData, uid: data.uid }); + const result = await plugins.hooks.fire('filter:post.get', { post: postData, uid: data.uid }); result.post.isMain = isMain; - plugins.hooks.fire('action:post.save', { post: _.clone(result.post) }); + plugins.hooks.fire('action:post.save', { post: { ...result.post, _activitypub } }); return result.post; }; diff --git a/src/posts/delete.js b/src/posts/delete.js index 94f73cf4940f..e4e4e5943827 100644 --- a/src/posts/delete.js +++ b/src/posts/delete.js @@ -9,6 +9,7 @@ const user = require('../user'); const notifications = require('../notifications'); const plugins = require('../plugins'); const flags = require('../flags'); +const activitypub = require('../activitypub'); module.exports = function (Posts) { Posts.delete = async function (pid, uid) { @@ -81,6 +82,8 @@ module.exports = function (Posts) { deleteDiffs(pids), deleteFromUploads(pids), db.sortedSetsRemove(['posts:pid', 'posts:votes', 'posts:flagged'], pids), + Posts.attachments.empty(pids), + activitypub.notes.delete(pids), ]); await resolveFlags(postData, uid); diff --git a/src/posts/edit.js b/src/posts/edit.js index a63f34cc483e..a8b702232e66 100644 --- a/src/posts/edit.js +++ b/src/posts/edit.js @@ -15,11 +15,10 @@ const slugify = require('../slugify'); const translator = require('../translator'); module.exports = function (Posts) { - pubsub.on('post:edit', (pid) => { - require('./cache').del(pid); - }); + pubsub.on('post:edit', pid => Posts.clearCachedPost(pid)); Posts.edit = async function (data) { + const { _activitypub } = data; const canEdit = await privileges.posts.canEdit(data.pid, data.uid); if (!canEdit.flag) { throw new Error(canEdit.message); @@ -89,9 +88,9 @@ module.exports = function (Posts) { }); await topics.syncBacklinks(returnPostData); - plugins.hooks.fire('action:post.edit', { post: _.clone(returnPostData), data: data, uid: data.uid }); + plugins.hooks.fire('action:post.edit', { post: { ...returnPostData, _activitypub }, data: data, uid: data.uid }); - require('./cache').del(String(postData.pid)); + Posts.clearCachedPost(String(postData.pid)); pubsub.publish('post:edit', String(postData.pid)); await Posts.parsePost(returnPostData); diff --git a/src/posts/index.js b/src/posts/index.js index 9db52c6b2743..59c61381b989 100644 --- a/src/posts/index.js +++ b/src/posts/index.js @@ -27,6 +27,8 @@ require('./queue')(Posts); require('./diffs')(Posts); require('./uploads')(Posts); +Posts.attachments = require('./attachments'); + Posts.exists = async function (pids) { return await db.exists( Array.isArray(pids) ? pids.map(pid => `post:${pid}`) : `post:${pids}` @@ -44,6 +46,7 @@ Posts.getPostsByPids = async function (pids, uid) { if (!Array.isArray(pids) || !pids.length) { return []; } + let posts = await Posts.getPostsData(pids); posts = await Promise.all(posts.map(Posts.parsePost)); const data = await plugins.hooks.fire('filter:post.getPosts', { posts: posts, uid: uid }); diff --git a/src/posts/parse.js b/src/posts/parse.js index a29668edb5d6..2b7d5081d491 100644 --- a/src/posts/parse.js +++ b/src/posts/parse.js @@ -32,6 +32,7 @@ let sanitizeConfig = { 'tabindex', 'title', 'translate', 'aria-*', 'data-*', ], }; +const allowedTypes = new Set(['default', 'plaintext', 'activitypub.note', 'activitypub.article']); module.exports = function (Posts) { Posts.urlRegex = { @@ -44,25 +45,36 @@ module.exports = function (Posts) { length: 5, }; - Posts.parsePost = async function (postData) { + Posts.parsePost = async function (postData, type) { if (!postData) { return postData; } - postData.content = String(postData.content || ''); + if (!type || !allowedTypes.has(type)) { + type = 'default'; + } + postData.content = String(postData.sourceContent || postData.content || ''); const cache = require('./cache'); - const pid = String(postData.pid); - const cachedContent = cache.get(pid); + const cacheKey = `${String(postData.pid)}|${type}`; + const cachedContent = cache.get(cacheKey); if (postData.pid && cachedContent !== undefined) { postData.content = cachedContent; return postData; } - const data = await plugins.hooks.fire('filter:parse.post', { postData: postData }); - data.postData.content = translator.escape(data.postData.content); - if (data.postData.pid) { - cache.set(pid, data.postData.content); + ({ postData } = await plugins.hooks.fire('filter:parse.post', { postData, type })); + postData.content = translator.escape(postData.content); + if (postData.pid) { + cache.set(cacheKey, postData.content); } - return data.postData; + + return postData; + }; + + Posts.clearCachedPost = function (pid) { + const cache = require('./cache'); + allowedTypes.forEach((type) => { + cache.del(`${String(pid)}|${type}`); + }); }; Posts.parseSignature = async function (userData, uid) { diff --git a/src/posts/summary.js b/src/posts/summary.js index 364baad1f7d8..fc39428c83db 100644 --- a/src/posts/summary.js +++ b/src/posts/summary.js @@ -3,6 +3,7 @@ const validator = require('validator'); const _ = require('lodash'); +const nconf = require('nconf'); const topics = require('../topics'); const user = require('../user'); @@ -20,7 +21,7 @@ module.exports = function (Posts) { options.parse = options.hasOwnProperty('parse') ? options.parse : true; options.extraFields = options.hasOwnProperty('extraFields') ? options.extraFields : []; - const fields = ['pid', 'tid', 'content', 'uid', 'timestamp', 'deleted', 'upvotes', 'downvotes', 'replies', 'handle'].concat(options.extraFields); + const fields = ['pid', 'tid', 'toPid', 'url', 'content', 'uid', 'timestamp', 'deleted', 'upvotes', 'downvotes', 'replies', 'handle'].concat(options.extraFields); let posts = await Posts.getPostsFields(pids, fields); posts = posts.filter(Boolean); @@ -44,6 +45,10 @@ module.exports = function (Posts) { if (!uidToUser.hasOwnProperty(post.uid)) { post.uid = 0; } + + // toPid is nullable so it is casted separately + post.toPid = utils.isNumber(post.toPid) ? parseInt(post.toPid, 10) : post.toPid; + post.user = uidToUser[post.uid]; Posts.overrideGuestHandle(post, post.handle); post.handle = undefined; @@ -52,6 +57,11 @@ module.exports = function (Posts) { post.isMainPost = post.topic && post.pid === post.topic.mainPid; post.deleted = post.deleted === 1; post.timestampISO = utils.toISOString(post.timestamp); + + // url only applies to remote posts; assume permalink otherwise + if (utils.isNumber(post.pid)) { + post.url = `${nconf.get('url')}/post/${post.pid}`; + } }); posts = posts.filter(post => tidToTopic[post.tid]); diff --git a/src/posts/tools.js b/src/posts/tools.js index daa5bde18987..e12fa866fa59 100644 --- a/src/posts/tools.js +++ b/src/posts/tools.js @@ -33,7 +33,7 @@ module.exports = function (Posts) { } let post; if (isDelete) { - require('./cache').del(pid); + Posts.clearCachedPost(pid); post = await Posts.delete(pid, uid); } else { post = await Posts.restore(pid, uid); diff --git a/src/posts/topics.js b/src/posts/topics.js index 8e94db301783..7f0fe31e4f28 100644 --- a/src/posts/topics.js +++ b/src/posts/topics.js @@ -17,7 +17,7 @@ module.exports = function (Posts) { pids = isArray ? pids : [pids]; const postData = await Posts.getPostsFields(pids, ['tid']); const topicData = await topics.getTopicsFields(postData.map(t => t.tid), ['mainPid']); - const result = pids.map((pid, i) => parseInt(pid, 10) === parseInt(topicData[i].mainPid, 10)); + const result = pids.map((pid, i) => String(pid) === String(topicData[i].mainPid)); return isArray ? result : result[0]; }; diff --git a/src/posts/user.js b/src/posts/user.js index 850ed4c6133d..a224a960c79e 100644 --- a/src/posts/user.js +++ b/src/posts/user.js @@ -11,6 +11,7 @@ const groups = require('../groups'); const meta = require('../meta'); const plugins = require('../plugins'); const privileges = require('../privileges'); +const utils = require('../utils'); module.exports = function (Posts) { Posts.getUserInfoForPosts = async function (uids, uid) { @@ -115,10 +116,10 @@ module.exports = function (Posts) { } Posts.isOwner = async function (pids, uid) { - uid = parseInt(uid, 10); + uid = utils.isNumber(uid) ? parseInt(uid, 10) : uid; const isArray = Array.isArray(pids); pids = isArray ? pids : [pids]; - if (uid <= 0) { + if (utils.isNumber(uid) && uid <= 0) { return isArray ? pids.map(() => false) : false; } const postData = await Posts.getPostsFields(pids, ['uid']); diff --git a/src/posts/votes.js b/src/posts/votes.js index bfe5e1e47fd4..46254028ebc5 100644 --- a/src/posts/votes.js +++ b/src/posts/votes.js @@ -8,6 +8,7 @@ const topics = require('../topics'); const plugins = require('../plugins'); const privileges = require('../privileges'); const translator = require('../translator'); +const utils = require('../utils'); module.exports = function (Posts) { const votesInProgress = {}; @@ -99,17 +100,17 @@ module.exports = function (Posts) { }; function voteInProgress(pid, uid) { - return Array.isArray(votesInProgress[uid]) && votesInProgress[uid].includes(parseInt(pid, 10)); + return Array.isArray(votesInProgress[uid]) && votesInProgress[uid].includes(String(pid)); } function putVoteInProgress(pid, uid) { votesInProgress[uid] = votesInProgress[uid] || []; - votesInProgress[uid].push(parseInt(pid, 10)); + votesInProgress[uid].push(String(pid)); } function clearVoteProgress(pid, uid) { if (Array.isArray(votesInProgress[uid])) { - const index = votesInProgress[uid].indexOf(parseInt(pid, 10)); + const index = votesInProgress[uid].indexOf(String(pid)); if (index !== -1) { votesInProgress[uid].splice(index, 1); } @@ -171,8 +172,7 @@ module.exports = function (Posts) { } async function vote(type, unvote, pid, uid, voteStatus) { - uid = parseInt(uid, 10); - if (uid <= 0) { + if (utils.isNumber(uid) && parseInt(uid, 10) <= 0) { throw new Error('[[error:not-logged-in]]'); } const now = Date.now(); diff --git a/src/privileges/categories.js b/src/privileges/categories.js index 7ccec5609d64..6061b28abed8 100644 --- a/src/privileges/categories.js +++ b/src/privileges/categories.js @@ -153,6 +153,7 @@ privsCategories.can = async function (privilege, cid, uid) { if (!cid) { return false; } + const [disabled, isAdmin, isAllowed] = await Promise.all([ categories.getCategoryField(cid, 'disabled'), user.isAdministrator(uid), diff --git a/src/privileges/helpers.js b/src/privileges/helpers.js index 58df456ea9e3..c858eebe600a 100644 --- a/src/privileges/helpers.js +++ b/src/privileges/helpers.js @@ -15,6 +15,7 @@ const helpers = module.exports; const uidToSystemGroup = { 0: 'guests', '-1': 'spiders', + '-2': 'fediverse', }; helpers.isUsersAllowedTo = async function (privilege, uids, cid) { diff --git a/src/privileges/posts.js b/src/privileges/posts.js index fbd685828254..6a930e27b3a1 100644 --- a/src/privileges/posts.js +++ b/src/privileges/posts.js @@ -7,6 +7,7 @@ const meta = require('../meta'); const posts = require('../posts'); const topics = require('../topics'); const user = require('../user'); +const activitypub = require('../activitypub'); const helpers = require('./helpers'); const plugins = require('../plugins'); const utils = require('../utils'); @@ -115,6 +116,7 @@ privsPosts.filter = async function (privilege, pids, uid) { }; privsPosts.canEdit = async function (pid, uid) { + const isRemote = activitypub.helpers.isUri(pid); const results = await utils.promiseParallel({ isAdmin: user.isAdministrator(uid), isMod: posts.isModerator([pid], uid), @@ -130,14 +132,14 @@ privsPosts.canEdit = async function (pid, uid) { } if ( - !results.isMod && + !isRemote && !results.isMod && meta.config.postEditDuration && (Date.now() - results.postData.timestamp > meta.config.postEditDuration * 1000) ) { return { flag: false, message: `[[error:post-edit-duration-expired, ${meta.config.postEditDuration}]]` }; } if ( - !results.isMod && + !isRemote && !results.isMod && meta.config.newbiePostEditDuration > 0 && meta.config.newbieReputationThreshold > results.userData.reputation && Date.now() - results.postData.timestamp > meta.config.newbiePostEditDuration * 1000 @@ -154,7 +156,7 @@ privsPosts.canEdit = async function (pid, uid) { return { flag: false, message: '[[error:post-deleted]]' }; } - results.pid = parseInt(pid, 10); + results.pid = utils.isNumber(pid) ? parseInt(pid, 10) : pid; results.uid = uid; const result = await plugins.hooks.fire('filter:privileges.posts.edit', results); @@ -222,6 +224,12 @@ privsPosts.canPurge = async function (pid, uid) { isAdmin: user.isAdministrator(uid), isModerator: user.isModerator(uid, cid), }); + + // Allow remote posts to purge themselves (as:Delete received) + if (activitypub.helpers.isUri(pid) && results.owner) { + results.purge = true; + } + return (results.purge && (results.owner || results.isModerator)) || results.isAdmin; }; diff --git a/src/privileges/topics.js b/src/privileges/topics.js index 5421876fb299..a53f34bd98c0 100644 --- a/src/privileges/topics.js +++ b/src/privileges/topics.js @@ -9,6 +9,7 @@ const user = require('../user'); const helpers = require('./helpers'); const categories = require('../categories'); const plugins = require('../plugins'); +const utils = require('../utils'); const privsCategories = require('./categories'); const privsTopics = module.exports; @@ -123,12 +124,18 @@ privsTopics.filterUids = async function (privilege, tid, uids) { privsTopics.canPurge = async function (tid, uid) { const cid = await topics.getTopicField(tid, 'cid'); - const [purge, owner, isAdmin, isModerator] = await Promise.all([ + let [purge, owner, isAdmin, isModerator] = await Promise.all([ privsCategories.isUserAllowedTo('purge', cid, uid), topics.isOwner(tid, uid), user.isAdministrator(uid), user.isModerator(uid, cid), ]); + + // Allow remote posts to purge themselves (as:Delete received) + if (!utils.isNumber(tid) && owner) { + purge = true; + } + return (purge && (owner || isModerator)) || isAdmin; }; diff --git a/src/request.js b/src/request.js index 8b3cd74daa11..97590077ef69 100644 --- a/src/request.js +++ b/src/request.js @@ -7,12 +7,13 @@ exports.jar = function () { return new CookieJar(); }; +// Initialize fetch - somewhat hacky, but it's required for globalDispatcher to be available + async function call(url, method, { body, timeout, jar, ...config } = {}) { let fetchImpl = fetch; if (jar) { fetchImpl = fetchCookie(fetch, jar); } - const jsonTest = /application\/([a-z]+\+)?json/; const opts = { ...config, @@ -33,6 +34,16 @@ async function call(url, method, { body, timeout, jar, ...config } = {}) { opts.body = body; } } + // Workaround for https://github.com/nodejs/undici/issues/1305 + if (global[Symbol.for('undici.globalDispatcher.1')] !== undefined) { + class FetchAgent extends global[Symbol.for('undici.globalDispatcher.1')].constructor { + dispatch(opts, handler) { + delete opts.headers['sec-fetch-mode']; + return super.dispatch(opts, handler); + } + } + opts.dispatcher = new FetchAgent(); + } const response = await fetchImpl(url, opts); diff --git a/src/routes/activitypub.js b/src/routes/activitypub.js new file mode 100644 index 000000000000..9f32715ebce2 --- /dev/null +++ b/src/routes/activitypub.js @@ -0,0 +1,46 @@ +'use strict'; + +const helpers = require('./helpers'); + +module.exports = function (app, middleware, controllers) { + helpers.setupPageRoute(app, '/world', [middleware.activitypub.enabled], controllers.activitypub.topics.list); + + /** + * These controllers only respond if the sender is making an json+activitypub style call (i.e. S2S-only) + * + * - See middleware.activitypub.assertS2S + */ + + const middlewares = [ + middleware.activitypub.enabled, + middleware.activitypub.assertS2S, + middleware.activitypub.configureResponse, + ]; + + const inboxMiddlewares = [ + middleware.activitypub.validate, + middleware.activitypub.resolveObjects, + ]; + + app.get('/actor', middlewares, controllers.activitypub.actors.application); + app.post('/inbox', [...middlewares, ...inboxMiddlewares], controllers.activitypub.postInbox); + + app.get('/uid/:uid', [...middlewares, middleware.assert.user], controllers.activitypub.actors.user); + app.get('/user/:userslug', [...middlewares, middleware.exposeUid, middleware.assert.user], controllers.activitypub.actors.userBySlug); + app.get('/uid/:uid/inbox', [...middlewares, middleware.assert.user], controllers.activitypub.getInbox); + app.post('/uid/:uid/inbox', [...middlewares, middleware.assert.user, ...inboxMiddlewares], controllers.activitypub.postInbox); + app.get('/uid/:uid/outbox', [...middlewares, middleware.assert.user], controllers.activitypub.getOutbox); + app.post('/uid/:uid/outbox', [...middlewares, middleware.assert.user], controllers.activitypub.postOutbox); + app.get('/uid/:uid/following', [...middlewares, middleware.assert.user], controllers.activitypub.getFollowing); + app.get('/uid/:uid/followers', [...middlewares, middleware.assert.user], controllers.activitypub.getFollowers); + + app.get('/post/:pid', [...middlewares, middleware.assert.post], controllers.activitypub.actors.note); + + app.get('/topic/:tid/:slug?', [...middlewares, middleware.assert.topic], controllers.activitypub.actors.topic); + + app.get('/category/:cid/:slug?', [...middlewares, middleware.assert.category], controllers.activitypub.actors.category); + app.get('/category/:cid/inbox', [...middlewares, middleware.assert.category], controllers.activitypub.getInbox); + app.post('/category/:cid/inbox', [...inboxMiddlewares, middleware.assert.category, ...inboxMiddlewares], controllers.activitypub.postInbox); + app.get('/category/:cid/outbox', [...middlewares, middleware.assert.category], controllers.activitypub.getCategoryOutbox); + app.post('/category/:cid/outbox', [...middlewares, middleware.assert.category], controllers.activitypub.postOutbox); +}; diff --git a/src/routes/admin.js b/src/routes/admin.js index 01e228dabed8..c4b2d92e3e92 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -16,6 +16,7 @@ module.exports = function (app, name, middleware, controllers) { helpers.setupAdminPageRoute(app, `/${name}/manage/categories`, middlewares, controllers.admin.categories.getAll); helpers.setupAdminPageRoute(app, `/${name}/manage/categories/:category_id`, middlewares, controllers.admin.categories.get); helpers.setupAdminPageRoute(app, `/${name}/manage/categories/:category_id/analytics`, middlewares, controllers.admin.categories.getAnalytics); + helpers.setupAdminPageRoute(app, `/${name}/manage/categories/:category_id/federation`, middlewares, controllers.admin.categories.getFederation); helpers.setupAdminPageRoute(app, `/${name}/manage/privileges/:cid?`, middlewares, controllers.admin.privileges.get); helpers.setupAdminPageRoute(app, `/${name}/manage/tags`, middlewares, controllers.admin.tags.get); diff --git a/src/routes/index.js b/src/routes/index.js index 4008f1565ad7..451d68a9feb9 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -22,6 +22,8 @@ const _mounts = { api: require('./api'), admin: require('./admin'), feed: require('./feeds'), + 'well-known': require('./well-known'), + activitypub: require('./activitypub'), }; _mounts.main = (app, middleware, controllers) => { @@ -154,9 +156,11 @@ function addCoreRoutes(app, router, middleware, mounts) { _mounts.api(router, middleware, controllers); _mounts.feed(router, middleware, controllers); + _mounts.activitypub(router, middleware, controllers); _mounts.main(router, middleware, controllers); _mounts.mod(router, middleware, controllers); _mounts.globalMod(router, middleware, controllers); + _mounts['well-known'](router, middleware, controllers); addRemountableRoutes(app, router, middleware, mounts); diff --git a/src/routes/user.js b/src/routes/user.js index 040f6cb06357..ad930679875e 100644 --- a/src/routes/user.js +++ b/src/routes/user.js @@ -41,9 +41,6 @@ module.exports = function (app, name, middleware, controllers) { setupPageRoute(app, `/${name}/:userslug/edit/username`, accountMiddlewares, controllers.accounts.edit.username); setupPageRoute(app, `/${name}/:userslug/edit/email`, accountMiddlewares, controllers.accounts.edit.email); setupPageRoute(app, `/${name}/:userslug/edit/password`, accountMiddlewares, controllers.accounts.edit.password); - app.use('/.well-known/change-password', (req, res) => { - res.redirect('/me/edit/password'); - }); setupPageRoute(app, `/${name}/:userslug/info`, accountMiddlewares, controllers.accounts.info.get); setupPageRoute(app, `/${name}/:userslug/settings`, accountMiddlewares, controllers.accounts.settings.get); setupPageRoute(app, `/${name}/:userslug/uploads`, accountMiddlewares, controllers.accounts.uploads.get); diff --git a/src/routes/well-known.js b/src/routes/well-known.js new file mode 100644 index 000000000000..ac54a1c21025 --- /dev/null +++ b/src/routes/well-known.js @@ -0,0 +1,9 @@ +'use strict'; + +module.exports = function (app, middleware, controllers) { + app.use('/.well-known/change-password', (req, res) => { + res.redirect('/me/edit/password'); + }); + + app.get('/.well-known/webfinger', controllers['well-known'].webfinger); +}; diff --git a/src/routes/write/categories.js b/src/routes/write/categories.js index 0f7aa1c4731a..066e75bb96c3 100644 --- a/src/routes/write/categories.js +++ b/src/routes/write/categories.js @@ -28,8 +28,11 @@ module.exports = function () { setupApiRoute(router, 'put', '/:cid/privileges/:privilege', [...middlewares, middleware.checkRequired.bind(null, ['member'])], controllers.write.categories.setPrivilege); setupApiRoute(router, 'delete', '/:cid/privileges/:privilege', [...middlewares, middleware.checkRequired.bind(null, ['member'])], controllers.write.categories.setPrivilege); - setupApiRoute(router, 'put', '/:cid/moderator/:uid', [...middlewares], controllers.write.categories.setModerator); - setupApiRoute(router, 'delete', '/:cid/moderator/:uid', [...middlewares], controllers.write.categories.setModerator); + setupApiRoute(router, 'put', '/:cid/moderator/:uid', [...middlewares, middleware.assert.category], controllers.write.categories.setModerator); + setupApiRoute(router, 'delete', '/:cid/moderator/:uid', [...middlewares, middleware.assert.category], controllers.write.categories.setModerator); + + setupApiRoute(router, 'put', '/:cid/follow', [...middlewares, middleware.activitypub.enabled, middleware.admin.checkPrivileges, middleware.assert.category], controllers.write.categories.follow); + setupApiRoute(router, 'delete', '/:cid/follow', [...middlewares, middleware.activitypub.enabled, middleware.admin.checkPrivileges, middleware.assert.category], controllers.write.categories.unfollow); return router; }; diff --git a/src/search.js b/src/search.js index df249ec1f611..7aa43ca1efae 100644 --- a/src/search.js +++ b/src/search.js @@ -10,6 +10,7 @@ const categories = require('./categories'); const user = require('./user'); const plugins = require('./plugins'); const privileges = require('./privileges'); +const activitypub = require('./activitypub'); const utils = require('./utils'); const search = module.exports; @@ -72,10 +73,24 @@ async function searchInContent(data) { } else if (data.searchIn === 'bookmarks') { pids = await searchInBookmarks(data, searchCids, searchUids); } else { - [pids, tids] = await Promise.all([ - doSearch('post', ['posts', 'titlesposts']), - doSearch('topic', ['titles', 'titlesposts']), - ]); + let result; + if (data.uid && activitypub.helpers.isUri(data.query)) { + const local = await activitypub.helpers.resolveLocalId(data.query); + if (local.type === 'post') { + result = [[local.id], []]; + } else { + result = await fetchRemoteObject(data.uid, data.query); + } + } + + if (result) { + [pids, tids] = result; + } else { + [pids, tids] = await Promise.all([ + doSearch('post', ['posts', 'titlesposts']), + doSearch('topic', ['titles', 'titlesposts']), + ]); + } } const mainPids = await topics.getMainPids(tids); @@ -118,6 +133,30 @@ async function searchInContent(data) { return Object.assign(returnData, metadata); } +async function fetchRemoteObject(uid, uri) { + try { + let id = uri; + let exists = await posts.exists(id); + let tid = exists ? await posts.getPostField(id, 'tid') : undefined; + if (!exists) { + let type; + ({ id, type } = await activitypub.get('uid', 0, id)); + if (activitypub._constants.acceptedPostTypes.includes(type)) { + exists = await posts.exists(id); + if (!exists) { + ({ tid } = await activitypub.notes.assert(uid, id)); + } else { + tid = await posts.getPostField(id, 'tid'); + } + } + } + + return tid ? [[id], []] : null; + } catch (e) { + return null; + } +} + async function searchInBookmarks(data, searchCids, searchUids) { const { uid, query, matchWords } = data; const allPids = []; diff --git a/src/socket.io/helpers.js b/src/socket.io/helpers.js index 7286b79e8e75..66e568d1b270 100644 --- a/src/socket.io/helpers.js +++ b/src/socket.io/helpers.js @@ -89,7 +89,7 @@ SocketHelpers.sendNotificationToPostOwner = async function (pid, fromuid, comman if (!pid || !fromuid || !notification) { return; } - fromuid = parseInt(fromuid, 10); + fromuid = utils.isNumber(fromuid) ? parseInt(fromuid, 10) : fromuid; const postData = await posts.getPostFields(pid, ['tid', 'uid', 'content']); const [canRead, isIgnoring] = await Promise.all([ privileges.posts.can('topics:read', pid, postData.uid), diff --git a/src/socket.io/posts/tools.js b/src/socket.io/posts/tools.js index 44b488216edd..a2dc02e27a9b 100644 --- a/src/socket.io/posts/tools.js +++ b/src/socket.io/posts/tools.js @@ -19,7 +19,7 @@ module.exports = function (SocketPosts) { } const cid = await posts.getCidByPid(data.pid); const results = await utils.promiseParallel({ - posts: posts.getPostFields(data.pid, ['deleted', 'bookmarks', 'uid', 'ip', 'flagId']), + posts: posts.getPostFields(data.pid, ['deleted', 'bookmarks', 'uid', 'ip', 'flagId', 'url']), isAdmin: user.isAdministrator(socket.uid), isGlobalMod: user.isGlobalModerator(socket.uid), isModerator: user.isModerator(socket.uid, cid), @@ -36,7 +36,8 @@ module.exports = function (SocketPosts) { }); const postData = results.posts; - postData.absolute_url = `${nconf.get('url')}/post/${data.pid}`; + postData.pid = data.pid; + postData.absolute_url = `${nconf.get('url')}/post/${encodeURIComponent(data.pid)}`; postData.bookmarked = results.bookmarked; postData.selfPost = socket.uid && socket.uid === postData.uid; postData.display_edit_tools = results.canEdit.flag; @@ -48,6 +49,7 @@ module.exports = function (SocketPosts) { postData.display_change_owner_tools = results.isAdmin || results.isModerator; postData.display_ip_ban = (results.isAdmin || results.isGlobalMod) && !postData.selfPost; postData.display_history = results.history && results.canViewHistory; + postData.display_original_url = !utils.isNumber(data.pid); postData.flags = { flagId: parseInt(results.posts.flagId, 10) || null, can: results.canFlag.flag, diff --git a/src/socket.io/topics/tags.js b/src/socket.io/topics/tags.js index a8f86eee192a..c5d26956920e 100644 --- a/src/socket.io/topics/tags.js +++ b/src/socket.io/topics/tags.js @@ -68,6 +68,7 @@ module.exports = function (SocketTopics) { } } data.cids = await categories.getCidsByPrivilege('categories:cid', uid, 'topics:read'); + data.cids = data.cids.filter(cid => cid !== -1); return await method(data); } @@ -106,7 +107,8 @@ module.exports = function (SocketTopics) { const start = parseInt(data.after, 10); const stop = start + 99; - const cids = await categories.getCidsByPrivilege('categories:cid', socket.uid, 'topics:read'); + let cids = await categories.getCidsByPrivilege('categories:cid', socket.uid, 'topics:read'); + cids = cids.filter(cid => cid !== -1); const tags = await topics.getCategoryTagsData(cids, start, stop); return { tags: tags.filter(Boolean), nextStart: stop + 1 }; }; diff --git a/src/topics/create.js b/src/topics/create.js index 0d6ee1bc190c..8a6c8dafe62d 100644 --- a/src/topics/create.js +++ b/src/topics/create.js @@ -9,6 +9,7 @@ const slugify = require('../slugify'); const plugins = require('../plugins'); const analytics = require('../analytics'); const user = require('../user'); +const activitypub = require('../activitypub'); const meta = require('../meta'); const posts = require('../posts'); const privileges = require('../privileges'); @@ -20,7 +21,7 @@ module.exports = function (Topics) { // This is an internal method, consider using Topics.post instead const timestamp = data.timestamp || Date.now(); - const tid = await db.incrObjectField('global', 'nextTid'); + const tid = data.tid || await db.incrObjectField('global', 'nextTid'); let topicData = { tid: tid, @@ -49,6 +50,12 @@ module.exports = function (Topics) { `cid:${topicData.cid}:tids:create`, `cid:${topicData.cid}:uid:${topicData.uid}:tids`, ]; + const countedSortedSetKeys = [ + ...['views', 'posts', 'votes'].map(prop => `${topicData.cid === -1 ? 'topicsRemote' : 'topics'}:${prop}`), + `cid:${topicData.cid}:tids:votes`, + `cid:${topicData.cid}:tids:posts`, + `cid:${topicData.cid}:tids:views`, + ]; const scheduled = timestamp > Date.now(); if (scheduled) { @@ -57,12 +64,7 @@ module.exports = function (Topics) { await Promise.all([ db.sortedSetsAdd(timestampedSortedSetKeys, timestamp, topicData.tid), - db.sortedSetsAdd([ - 'topics:views', 'topics:posts', 'topics:votes', - `cid:${topicData.cid}:tids:votes`, - `cid:${topicData.cid}:tids:posts`, - `cid:${topicData.cid}:tids:views`, - ], 0, topicData.tid), + db.sortedSetsAdd(countedSortedSetKeys, 0, topicData.tid), user.addTopicIdToUser(topicData.uid, topicData.tid, timestamp), db.incrObjectField(`category:${topicData.cid}`, 'topic_count'), db.incrObjectField('global', 'topicCount'), @@ -82,7 +84,7 @@ module.exports = function (Topics) { const { uid } = data; const [categoryExists, canCreate, canTag, isAdmin] = await Promise.all([ - categories.exists(data.cid), + parseInt(data.cid, 10) > 0 ? categories.exists(data.cid) : true, privileges.categories.can('topics:create', data.cid, uid), privileges.categories.can('topics:tag', data.cid, uid), privileges.users.isAdministrator(uid), @@ -135,7 +137,7 @@ module.exports = function (Topics) { throw new Error('[[error:no-topic]]'); } - if (uid > 0 && settings.followTopicsOnCreate) { + if (utils.isNumber(uid) && uid > 0 && settings.followTopicsOnCreate) { await Topics.follow(postData.tid, uid); } const topicData = topics[0]; @@ -151,8 +153,11 @@ module.exports = function (Topics) { analytics.increment(['topics', `topics:byCid:${topicData.cid}`]); plugins.hooks.fire('action:topic.post', { topic: topicData, post: postData, data: data }); - if (parseInt(uid, 10) && !topicData.scheduled) { - user.notifications.sendTopicNotificationToFollowers(uid, topicData, postData); + if (!topicData.scheduled) { + if (utils.isNumber(uid)) { + // New topic notifications only sent for local-to-local follows only + user.notifications.sendTopicNotificationToFollowers(uid, topicData, postData); + } Topics.notifyTagFollowers(postData, uid); categories.notifyCategoryFollowers(postData, uid); } @@ -202,11 +207,11 @@ module.exports = function (Topics) { await Topics.follow(postData.tid, uid); } - if (parseInt(uid, 10)) { + if (parseInt(uid, 10) || activitypub.helpers.isUri(uid)) { user.setUserField(uid, 'lastonline', Date.now()); } - if (parseInt(uid, 10) || meta.config.allowGuestReplyNotifications) { + if (parseInt(uid, 10) || activitypub.helpers.isUri(uid) || meta.config.allowGuestReplyNotifications) { const { displayname } = postData.user; Topics.notifyFollowers(postData, uid, { diff --git a/src/topics/events.js b/src/topics/events.js index 13ab489c63a9..ca2ca10edbd9 100644 --- a/src/topics/events.js +++ b/src/topics/events.js @@ -10,6 +10,7 @@ const categories = require('../categories'); const plugins = require('../plugins'); const translator = require('../translator'); const privileges = require('../privileges'); +const activitypub = require('../activitypub'); const utils = require('../utils'); const helpers = require('../helpers'); @@ -68,6 +69,10 @@ Events._types = { icon: 'fa-code-fork', translation: async (event, language) => translateEventArgs(event, language, 'topic:user-forked-topic', renderUser(event), `${relative_path}${event.href}`, renderTimeago(event)), }, + announce: { + icon: 'fa-share-alt', + translation: async (event, language) => translateEventArgs(event, language, 'activitypub:topic-event-announce', renderUser(event), `${relative_path}${event.href}`, renderTimeago(event)), + }, }; Events.init = async () => { @@ -129,9 +134,19 @@ Events.get = async (tid, uid, reverse = false) => { return events; }; +Events.find = async (tid, match) => { + let eventIds = await db.getSortedSetRangeWithScores(`topic:${tid}:events`, 0, -1); + const keys = eventIds.map(obj => `topicEvent:${obj.value}`); + eventIds = eventIds.map(obj => obj.value); + const events = await db.getObjects(keys); + eventIds = eventIds.filter((id, idx) => _.isMatch(events[idx], match)); + + return eventIds; +}; + async function getUserInfo(uids) { - uids = uids.filter((uid, idx) => !isNaN(parseInt(uid, 10)) && uids.indexOf(uid) === idx); - const userData = await user.getUsersFields(uids, ['picture', 'username', 'userslug']); + uids = new Set(uids); // eliminate dupes + const userData = await user.getUsersFields(Array.from(uids), ['picture', 'username', 'userslug']); const userMap = userData.reduce((memo, cur) => memo.set(cur.uid, cur), new Map()); userMap.set('system', { system: true, @@ -162,6 +177,19 @@ async function modifyEvent({ tid, uid, eventIds, timestamps, events }) { }); } + // Add post announces + const announces = await activitypub.notes.announce.list({ tid }); + announces.forEach(({ actor, pid, timestamp }) => { + events.push({ + type: 'announce', + uid: actor, + href: `/post/${encodeURIComponent(pid)}`, + pid, + timestamp, + }); + timestamps.push(timestamp); + }); + const [users, fromCategories, userSettings] = await Promise.all([ getUserInfo(events.map(event => event.uid).filter(Boolean)), getCategoryInfo(events.map(event => event.fromCid).filter(Boolean)), @@ -189,8 +217,9 @@ async function modifyEvent({ tid, uid, eventIds, timestamps, events }) { event.id = parseInt(eventIds[idx], 10); event.timestamp = timestamps[idx]; event.timestampISO = new Date(timestamps[idx]).toISOString(); + event.uid = utils.isNumber(event.uid) ? parseInt(event.uid, 10) : event.uid; if (event.hasOwnProperty('uid')) { - event.user = users.get(event.uid === 'system' ? 'system' : parseInt(event.uid, 10)); + event.user = users.get(event.uid === 'system' ? 'system' : event.uid); } if (event.hasOwnProperty('fromCid')) { event.fromCategory = fromCategories[event.fromCid]; diff --git a/src/topics/follow.js b/src/topics/follow.js index 2cd856134c74..a89dfa57c550 100644 --- a/src/topics/follow.js +++ b/src/topics/follow.js @@ -166,7 +166,7 @@ module.exports = function (Topics) { subject: title, bodyLong: postData.content, pid: postData.pid, - path: `/post/${postData.pid}`, + path: `/post/${encodeURIComponent(postData.pid)}`, tid: postData.topic.tid, from: exceptUid, topicTitle: title, diff --git a/src/topics/index.js b/src/topics/index.js index 5724d8a276ba..5d015c6c90b7 100644 --- a/src/topics/index.js +++ b/src/topics/index.js @@ -10,6 +10,7 @@ const plugins = require('../plugins'); const meta = require('../meta'); const user = require('../user'); const categories = require('../categories'); +const activitypub = require('../activitypub'); const privileges = require('../privileges'); const social = require('../social'); @@ -69,8 +70,12 @@ Topics.getTopicsByTids = async function (tids, options) { async function loadTopics() { const topics = await Topics.getTopicsData(tids); - const uids = _.uniq(topics.map(t => t && t.uid && t.uid.toString()).filter(v => utils.isNumber(v))); - const cids = _.uniq(topics.map(t => t && t.cid && t.cid.toString()).filter(v => utils.isNumber(v))); + const uids = _.uniq(topics + .map(t => t && t.uid && t.uid.toString()) + .filter(v => utils.isNumber(v) || activitypub.helpers.isUri(v))); + const cids = _.uniq(topics + .map(t => t && t.cid && t.cid.toString()) + .filter(v => utils.isNumber(v))); const guestTopics = topics.filter(t => t && t.uid === 0); async function loadGuestHandles() { @@ -309,4 +314,11 @@ Topics.search = async function (tid, term) { return Array.isArray(result) ? result : result.ids; }; +Topics.getPidByIndex = async function (tid, index) { + index -= 2; // zset only stores replies, index is not zero-indexed, so offset by 2. + return index > 0 ? + (await db.getSortedSetRange(`tid:${tid}:posts`, index, index)).pop() : + await Topics.getTopicField(tid, 'mainPid'); +}; + require('../promisify')(Topics); diff --git a/src/topics/posts.js b/src/topics/posts.js index 73eb29b9f96c..272b4d77acdf 100644 --- a/src/topics/posts.js +++ b/src/topics/posts.js @@ -9,6 +9,7 @@ const db = require('../database'); const user = require('../user'); const posts = require('../posts'); const meta = require('../meta'); +const activitypub = require('../activitypub'); const plugins = require('../plugins'); const utils = require('../utils'); @@ -110,7 +111,9 @@ module.exports = function (Topics) { const pids = postData.map(post => post && post.pid); async function getPostUserData(field, method) { - const uids = _.uniq(postData.filter(p => p && parseInt(p[field], 10) >= 0).map(p => p[field])); + const uids = _.uniq(postData + .filter(p => p && (activitypub.helpers.isUri(p[field]) || parseInt(p[field], 10) >= 0)) + .map(p => p[field])); const userData = await method(uids); return _.zipObject(uids, userData); } @@ -176,7 +179,9 @@ module.exports = function (Topics) { }; Topics.addParentPosts = async function (postData) { - let parentPids = postData.map(postObj => (postObj && postObj.hasOwnProperty('toPid') ? parseInt(postObj.toPid, 10) : null)).filter(Boolean); + let parentPids = postData + .filter(p => p && p.hasOwnProperty('toPid') && (activitypub.helpers.isUri(p.toPid) || utils.isNumber(p.toPid))) + .map(postObj => postObj.toPid); if (!parentPids.length) { return; @@ -233,7 +238,7 @@ module.exports = function (Topics) { } isDeleted = await posts.getPostField(pids[0], 'deleted'); if (!isDeleted) { - return parseInt(pids[0], 10); + return pids[0]; } index += 1; } while (isDeleted); @@ -241,7 +246,7 @@ module.exports = function (Topics) { Topics.addPostToTopic = async function (tid, postData) { const mainPid = await Topics.getTopicField(tid, 'mainPid'); - if (!parseInt(mainPid, 10)) { + if (!mainPid) { await Topics.setTopicField(tid, 'mainPid', postData.pid); } else { const upvotes = parseInt(postData.upvotes, 10) || 0; @@ -290,9 +295,18 @@ module.exports = function (Topics) { incrementFieldAndUpdateSortedSet(tid, 'postcount', -1, 'topics:posts'); }; - Topics.increaseViewCount = async function (tid) { - const cid = await Topics.getTopicField(tid, 'cid'); - incrementFieldAndUpdateSortedSet(tid, 'viewcount', 1, ['topics:views', `cid:${cid}:tids:views`]); + Topics.increaseViewCount = async function (req, tid) { + const allow = req.uid > 0 || (meta.config.guestsIncrementTopicViews && req.uid === 0); + if (allow) { + req.session.tids_viewed = req.session.tids_viewed || {}; + const now = Date.now(); + const interval = meta.config.incrementTopicViewsInterval * 60000; + if (!req.session.tids_viewed[tid] || req.session.tids_viewed[tid] < now - interval) { + const cid = await Topics.getTopicField(tid, 'cid'); + incrementFieldAndUpdateSortedSet(tid, 'viewcount', 1, ['topics:views', `cid:${cid}:tids:views`]); + req.session.tids_viewed[tid] = now; + } + } }; async function incrementFieldAndUpdateSortedSet(tid, field, by, set) { @@ -429,7 +443,7 @@ module.exports = function (Topics) { await Topics.events.log(tid, { uid, type: 'backlink', - href: `/post/${pid}`, + href: `/post/${encodeURIComponent(pid)}`, }); })); diff --git a/src/topics/recent.js b/src/topics/recent.js index ff8a58368c30..b331ed453579 100644 --- a/src/topics/recent.js +++ b/src/topics/recent.js @@ -71,12 +71,19 @@ module.exports = function (Topics) { }; Topics.updateRecent = async function (tid, timestamp) { - let data = { tid: tid, timestamp: timestamp }; + let data = { tid, timestamp }; + + // Topics in /world are excluded from /recent + const cid = await Topics.getTopicField(tid, 'cid'); + if (cid === -1) { + return; + } + if (plugins.hooks.hasListeners('filter:topics.updateRecent')) { - data = await plugins.hooks.fire('filter:topics.updateRecent', { tid: tid, timestamp: timestamp }); + data = await plugins.hooks.fire('filter:topics.updateRecent', data); } if (data && data.tid && data.timestamp) { - await db.sortedSetAdd('topics:recent', data.timestamp, data.tid); + await db.sortedSetAdd(`topics:recent`, data.timestamp, data.tid); } }; }; diff --git a/src/topics/scheduled.js b/src/topics/scheduled.js index 0a91067d59e3..364fa28c6fbe 100644 --- a/src/topics/scheduled.js +++ b/src/topics/scheduled.js @@ -10,6 +10,7 @@ const socketHelpers = require('../socket.io/helpers'); const topics = require('./index'); const groups = require('../groups'); const user = require('../user'); +const api = require('../api'); const Scheduled = module.exports; @@ -47,6 +48,7 @@ async function postTids(tids) { sendNotifications(uids, topicsData), updateUserLastposttimes(uids, topicsData), updateGroupPosts(uids, topicsData), + federatePosts(uids, topicsData), ...topicsData.map(topicData => unpin(topicData.tid, topicData)), )); } @@ -151,6 +153,14 @@ async function updateGroupPosts(uids, topicsData) { })); } +function federatePosts(uids, topicData) { + topicData.forEach(({ mainPid: pid }, idx) => { + const uid = uids[idx]; + + api.activitypub.create.note({ uid }, { pid }); + }); +} + async function shiftPostTimes(tid, timestamp) { const pids = (await posts.getPidsFromSet(`tid:${tid}:posts`, 0, -1, false)); // Leaving other related score values intact, since they reflect post order correctly, and it seems that's good enough diff --git a/src/topics/sorted.js b/src/topics/sorted.js index 98292f0ddb3a..f423bd1c2ab0 100644 --- a/src/topics/sorted.js +++ b/src/topics/sorted.js @@ -47,7 +47,10 @@ module.exports = function (Topics) { if (params.sort === 'posts') { tids = await getTidsWithMostPostsInTerm(params.cids, params.uid, params.term); } else { - tids = await Topics.getLatestTidsFromSet('topics:tid', 0, -1, params.term); + const cids = await getCids(params.cids, params.uid); + tids = await Topics.getLatestTidsFromSet( + cids.map(cid => `cid:${cid}:tids:create`), 0, -1, params.term + ); } if (params.filter === 'watched') { @@ -84,13 +87,18 @@ module.exports = function (Topics) { return 'topics:recent'; } - async function getTidsWithMostPostsInTerm(cids, uid, term) { + async function getCids(cids, uid) { if (Array.isArray(cids)) { cids = await privileges.categories.filterCids('topics:read', cids, uid); } else { cids = await categories.getCidsByPrivilege('categories:cid', uid, 'topics:read'); + cids = cids.filter(cid => cid !== -1); } + return cids; + } + async function getTidsWithMostPostsInTerm(cids, uid, term) { + cids = await getCids(cids, uid); const pids = await db.getSortedSetRevRangeByScore( cids.map(cid => `cid:${cid}:pids`), 0, @@ -270,6 +278,7 @@ module.exports = function (Topics) { t && t.cid && !isCidIgnored[t.cid] && + (cids || parseInt(t.cid, 10) !== -1) && (!cids || cids.includes(String(t.cid))) && (!tags.length || tags.every(tag => t.tags.find(topicTag => topicTag.value === tag))) )).map(t => t.tid); diff --git a/src/topics/tags.js b/src/topics/tags.js index daab4e5f77ed..ee78a85f8e14 100644 --- a/src/topics/tags.js +++ b/src/topics/tags.js @@ -624,7 +624,7 @@ module.exports = function (Topics) { bodyShort: bodyShort, bodyLong: postData.content, pid: postData.pid, - path: `/post/${postData.pid}`, + path: `/post/${encodeURIComponent(postData.pid)}`, tid: postData.topic.tid, from: exceptUid, }); diff --git a/src/topics/unread.js b/src/topics/unread.js index e3f7483572b0..0e657a94f7c8 100644 --- a/src/topics/unread.js +++ b/src/topics/unread.js @@ -3,6 +3,7 @@ const async = require('async'); const _ = require('lodash'); +const validator = require('validator'); const db = require('../database'); const user = require('../user'); @@ -293,7 +294,7 @@ module.exports = function (Topics) { return false; } - tids = _.uniq(tids).filter(tid => tid && utils.isNumber(tid)); + tids = _.uniq(tids).filter(tid => tid && (utils.isNumber(tid) || validator.isUUID(tid))); if (!tids.length) { return false; diff --git a/src/topics/user.js b/src/topics/user.js index d3fbdc91ef5f..7a2265512c6e 100644 --- a/src/topics/user.js +++ b/src/topics/user.js @@ -1,15 +1,15 @@ 'use strict'; const db = require('../database'); +const utils = require('../utils'); module.exports = function (Topics) { Topics.isOwner = async function (tid, uid) { - uid = parseInt(uid, 10); - if (uid <= 0) { + if (utils.isNumber(uid) && parseInt(uid, 10) <= 0) { return false; } const author = await Topics.getTopicField(tid, 'uid'); - return author === uid; + return String(author) === String(uid); }; Topics.getUids = async function (tid) { diff --git a/src/upgrades/4.0.0/activitypub_setup.js b/src/upgrades/4.0.0/activitypub_setup.js new file mode 100644 index 000000000000..fdc93f2fdfc1 --- /dev/null +++ b/src/upgrades/4.0.0/activitypub_setup.js @@ -0,0 +1,34 @@ +'use strict'; + +const db = require('../../database'); +const meta = require('../../meta'); +const categories = require('../../categories'); +const slugify = require('../../slugify'); + +module.exports = { + name: 'Setting up default configs/privileges re: ActivityPub', + timestamp: Date.UTC(2024, 1, 22), + method: async () => { + // Disable ActivityPub (upgraded installs have to opt-in to AP) + meta.configs.set('activitypubEnabled', 0); + + // Set default privileges for world category + const install = require('../../install'); + await install.giveWorldPrivileges(); + + // Run through all categories and ensure their slugs are unique (incl. users/groups too) + const cids = await db.getSortedSetMembers('categories:cid'); + const names = await db.getObjectsFields(cids.map(cid => `category:${cid}`), cids.map(() => 'name')); + + const handles = await Promise.all(cids.map(async (cid, idx) => { + const { name } = names[idx]; + const handle = await categories.generateHandle(slugify(name)); + return handle; + })); + + await Promise.all([ + db.setObjectBulk(cids.map((cid, idx) => [`category:${cid}`, { handle: handles[idx] }])), + db.sortedSetAdd('categoryhandle:cid', cids, handles), + ]); + }, +}; diff --git a/src/upgrades/4.0.0/announces_zset.js b/src/upgrades/4.0.0/announces_zset.js new file mode 100644 index 000000000000..8330cc6dbd54 --- /dev/null +++ b/src/upgrades/4.0.0/announces_zset.js @@ -0,0 +1,37 @@ +'use strict'; + +const db = require('../../database'); +const batch = require('../../batch'); +const topics = require('../../topics'); + +module.exports = { + name: 'Save ActivityPub Announces in their own per-post sorted set', + timestamp: Date.UTC(2024, 4, 1), + method: async function () { + const { progress } = this; + const bulkOp = []; + + await batch.processSortedSet('topics:tid', async (tids) => { + await Promise.all(tids.map(async (tid) => { + const announces = await topics.events.find(tid, { + type: 'announce', + }); + + if (announces.length) { + await Promise.all(announces.map(async (eid) => { + const event = await db.getObject(`topicEvent:${eid}`); + if (['uid', 'pid', 'timestamp'].every(prop => event.hasOwnProperty(prop))) { + bulkOp.push([`pid:${event.pid}:announces`, event.timestamp, event.uid]); + } + })); + + await topics.events.purge(tid, announces); + } + })); + + progress.incr(tids.length); + }, { progress }); + + await db.sortedSetAddBulk(bulkOp); + }, +}; diff --git a/src/upgrades/4.0.0/fix_topic_zsets_for_uncategorized.js b/src/upgrades/4.0.0/fix_topic_zsets_for_uncategorized.js new file mode 100644 index 000000000000..4103cd81e1bd --- /dev/null +++ b/src/upgrades/4.0.0/fix_topic_zsets_for_uncategorized.js @@ -0,0 +1,36 @@ +// REMOVE THIS PRIOR TO 4.0 ALPHA + +'use strict'; + +const db = require('../../database'); + +module.exports = { + name: 'Fix topic sorted sets for uncategorized topics', + timestamp: Date.UTC(2024, 2, 26), + method: async function () { + const props = ['views', 'posts', 'votes']; + const { progress } = this; + const tids = await db.getSortedSetMembers('cid:-1:tids'); + progress.total = tids.length; + + const remove = []; + const add = []; + await Promise.all(props.map(async (prop) => { + const set = `topics:${prop}`; + const newSet = `topicsRemote:${prop}`; + + const scores = await db.sortedSetScores(set, tids); + scores.forEach((score, idx) => { + if (score !== null) { + remove.push([set, tids[idx]]); + add.push([newSet, score, tids[idx]]); + } + }); + })); + + await Promise.all([ + db.sortedSetRemoveBulk(remove), + db.sortedSetAddBulk(add), + ]); + }, +}; diff --git a/src/upgrades/4.0.0/remote_user_urls.js b/src/upgrades/4.0.0/remote_user_urls.js new file mode 100644 index 000000000000..2b5ee8088a69 --- /dev/null +++ b/src/upgrades/4.0.0/remote_user_urls.js @@ -0,0 +1,35 @@ +// REMOVE THIS PRIOR TO 4.0 ALPHA + +'use strict'; + +const db = require('../../database'); +const activitypub = require('../../activitypub'); + +module.exports = { + name: 'Re-assert all existing actors to save URL into hash', + timestamp: Date.UTC(2024, 3, 4), + method: async function () { + const batch = require('../../batch'); + const { progress } = this; + const interval = 1500; + + let actorIds = await db.getSortedSetMembers('usersRemote:lastCrawled'); + progress.total = actorIds.length; + const existing = await db.getObjectValues('remoteUrl:uid'); + const exists = actorIds.map(actorId => existing.includes(actorId)); + actorIds = actorIds.filter((_, idx) => !exists[idx]); + + // Increment ones that were already completed + progress.incr(progress.total - actorIds.length); + + await batch.processArray(actorIds, async (ids) => { + try { + await activitypub.actors.assert(ids, { update: true }); + } catch (e) { + // noop + } + + progress.incr(ids.length); + }, { progress, interval }); + }, +}; diff --git a/src/upgrades/4.0.0/searchable_remote_users.js b/src/upgrades/4.0.0/searchable_remote_users.js new file mode 100644 index 000000000000..7cc9abbfb8ae --- /dev/null +++ b/src/upgrades/4.0.0/searchable_remote_users.js @@ -0,0 +1,30 @@ +// REMOVE THIS PRIOR TO 4.0 ALPHA + +'use strict'; + +const db = require('../../database'); + +module.exports = { + name: 'Allow remote user profiles to be searched', + // remember, month is zero-indexed (so January is 0, December is 11) + timestamp: Date.UTC(2024, 2, 1), + method: async () => { + const ids = await db.getSortedSetMembers('usersRemote:lastCrawled'); + const data = await db.getObjectsFields(ids.map(id => `userRemote:${id}`), ['username', 'fullname']); + + const queries = data.reduce((memo, profile, idx) => { + if (profile && profile.username && profile.fullname) { + memo.zset.push(['ap.preferredUsername:sorted', 0, `${profile.username.toLowerCase()}:${ids[idx]}`]); + memo.zset.push(['ap.name:sorted', 0, `${profile.fullname.toLowerCase()}:${ids[idx]}`]); + memo.hash[profile.username.toLowerCase()] = ids[idx]; + } + + return memo; + }, { zset: [], hash: {} }); + + await Promise.all([ + db.sortedSetAddBulk(queries.zset), + db.setObject('handle:uid', queries.hash), + ]); + }, +}; diff --git a/src/user/categories.js b/src/user/categories.js index 1bae181ef56a..137cf595e336 100644 --- a/src/user/categories.js +++ b/src/user/categories.js @@ -4,11 +4,12 @@ const _ = require('lodash'); const db = require('../database'); const categories = require('../categories'); +const activitypub = require('../activitypub'); const plugins = require('../plugins'); module.exports = function (User) { User.setCategoryWatchState = async function (uid, cids, state) { - if (!(parseInt(uid, 10) > 0)) { + if (!activitypub.helpers.isUri(uid) && !(parseInt(uid, 10) > 0)) { return; } const isStateValid = Object.values(categories.watchStates).includes(parseInt(state, 10)); diff --git a/src/user/create.js b/src/user/create.js index 610d614e81a6..7cdfb3c4cf57 100644 --- a/src/user/create.js +++ b/src/user/create.js @@ -184,7 +184,7 @@ module.exports = function (User) { let { username } = userData; while (true) { /* eslint-disable no-await-in-loop */ - const exists = await meta.userOrGroupExists(username); + const exists = await meta.slugTaken(username); if (!exists) { return numTries ? username : null; } diff --git a/src/user/data.js b/src/user/data.js index c7e2d8b828f4..4454ec0d1aca 100644 --- a/src/user/data.js +++ b/src/user/data.js @@ -7,6 +7,7 @@ const _ = require('lodash'); const db = require('../database'); const meta = require('../meta'); const plugins = require('../plugins'); +const activitypub = require('../activitypub'); const utils = require('../utils'); const relative_path = nconf.get('relative_path'); @@ -49,13 +50,22 @@ module.exports = function (User) { return []; } - uids = uids.map(uid => (isNaN(uid) ? 0 : parseInt(uid, 10))); + uids = uids.map((uid) => { + if (utils.isNumber(uid)) { + return parseInt(uid, 10); + } else if (activitypub.helpers.isUri(uid)) { + return uid; + } + + return 0; + }); const fieldsToRemove = []; fields = fields.slice(); ensureRequiredFields(fields, fieldsToRemove); - const uniqueUids = _.uniq(uids).filter(uid => uid > 0); + const uniqueUids = _.uniq(uids).filter(uid => isFinite(uid) && uid > 0); + const remoteIds = _.uniq(uids).filter(uid => !isFinite(uid)); const results = await plugins.hooks.fire('filter:user.whitelistFields', { uids: uids, @@ -68,7 +78,11 @@ module.exports = function (User) { fields = fields.filter(value => value !== 'password'); } - const users = await db.getObjectsFields(uniqueUids.map(uid => `user:${uid}`), fields); + await activitypub.actors.assert(remoteIds); + const users = [ + ...await db.getObjectsFields(uniqueUids.map(uid => `user:${uid}`), fields), + ...await db.getObjectsFields(remoteIds.map(id => `userRemote:${id}`), fields), + ]; const result = await plugins.hooks.fire('filter:user.getFields', { uids: uniqueUids, users: users, @@ -80,7 +94,7 @@ module.exports = function (User) { } }); await modifyUserData(result.users, fields, fieldsToRemove); - return uidsToUsers(uids, uniqueUids, result.users); + return uidsToUsers(uids, [...uniqueUids, ...remoteIds], result.users); }; function ensureRequiredFields(fields, fieldsToRemove) { @@ -116,7 +130,7 @@ module.exports = function (User) { const uidToUser = _.zipObject(uniqueUids, usersData); const users = uids.map((uid) => { const user = uidToUser[uid] || { ...User.guestData }; - if (!parseInt(user.uid, 10)) { + if (!parseInt(user.uid, 10) && !activitypub.helpers.isUri(user.uid)) { user.username = (user.hasOwnProperty('oldUid') && parseInt(user.oldUid, 10)) ? '[[global:former-user]]' : '[[global:guest]]'; user.displayname = user.username; } @@ -202,7 +216,7 @@ module.exports = function (User) { user.email = validator.escape(user.email ? user.email.toString() : ''); } - if (!parseInt(user.uid, 10)) { + if (!parseInt(user.uid, 10) && !activitypub.helpers.isUri(user.uid)) { for (const [key, value] of Object.entries(User.guestData)) { user[key] = value; } @@ -232,7 +246,7 @@ module.exports = function (User) { } // User Icons - if (requestedFields.includes('picture') && user.username && parseInt(user.uid, 10) && !meta.config.defaultAvatar) { + if (requestedFields.includes('picture') && user.username && parseInt(user.uid, 10) !== 0 && !meta.config.defaultAvatar) { const iconBackgrounds = await User.getIconBackgrounds(user.uid); let bgColor = await User.getUserField(user.uid, 'icon:bgColor'); if (!iconBackgrounds.includes(bgColor)) { @@ -281,6 +295,11 @@ module.exports = function (User) { } } + // Always show full name for remote users + if (!utils.isNumber(user.uid)) { + showfullname = true; + } + user.displayname = validator.escape(String( meta.config.showFullnameAsDisplayName && showfullname && user.fullname ? user.fullname : @@ -334,7 +353,8 @@ module.exports = function (User) { }; User.setUserFields = async function (uid, data) { - await db.setObject(`user:${uid}`, data); + const userKey = isFinite(uid) ? `user:${uid}` : `userRemote:${uid}`; + await db.setObject(userKey, data); for (const [field, value] of Object.entries(data)) { plugins.hooks.fire('action:user.set', { uid, field, value, type: 'set' }); } diff --git a/src/user/follow.js b/src/user/follow.js index 2fc74f14245b..b2a236b9f718 100644 --- a/src/user/follow.js +++ b/src/user/follow.js @@ -1,7 +1,9 @@ 'use strict'; +const notifications = require('../notifications'); const plugins = require('../plugins'); +const activitypub = require('../activitypub'); const db = require('../database'); module.exports = function (User) { @@ -55,13 +57,15 @@ module.exports = function (User) { ]); } - const [followingCount, followerCount] = await Promise.all([ + const [followingCount, followingRemoteCount, followerCount, followerRemoteCount] = await Promise.all([ db.sortedSetCard(`following:${uid}`), + db.sortedSetCard(`followingRemote:${uid}`), db.sortedSetCard(`followers:${theiruid}`), + db.sortedSetCard(`followersRemote:${theiruid}`), ]); await Promise.all([ - User.setUserField(uid, 'followingCount', followingCount), - User.setUserField(theiruid, 'followerCount', followerCount), + User.setUserField(uid, 'followingCount', followingCount + followingRemoteCount), + User.setUserField(theiruid, 'followerCount', followerCount + followerRemoteCount), ]); } @@ -77,7 +81,11 @@ module.exports = function (User) { if (parseInt(uid, 10) <= 0) { return []; } - const uids = await db.getSortedSetRevRange(`${type}:${uid}`, start, stop); + const uids = await db.getSortedSetRevRange([ + `${type}:${uid}`, + `${type}Remote:${uid}`, + ], start, stop); + const data = await plugins.hooks.fire(`filter:user.${type}`, { uids: uids, uid: uid, @@ -88,9 +96,30 @@ module.exports = function (User) { } User.isFollowing = async function (uid, theirid) { - if (parseInt(uid, 10) <= 0 || parseInt(theirid, 10) <= 0) { + const isRemote = activitypub.helpers.isUri(theirid); + if (parseInt(uid, 10) <= 0 || (!isRemote && (theirid, 10) <= 0)) { return false; } - return await db.isSortedSetMember(`following:${uid}`, theirid); + const setPrefix = isRemote ? 'followingRemote' : 'following'; + return await db.isSortedSetMember(`${setPrefix}:${uid}`, theirid); + }; + + User.onFollow = async function (uid, targetUid) { + const userData = await User.getUserFields(uid, ['username', 'userslug']); + const { displayname } = userData; + + const notifObj = await notifications.create({ + type: 'follow', + bodyShort: `[[notifications:user-started-following-you, ${displayname}]]`, + nid: `follow:${targetUid}:uid:${uid}`, + from: uid, + path: `/uid/${targetUid}/followers`, + mergeId: 'notifications:user-started-following-you', + }); + if (!notifObj) { + return; + } + notifObj.user = userData; + await notifications.push(notifObj, [targetUid]); }; }; diff --git a/src/user/index.js b/src/user/index.js index 25f90c906b02..3590c1c8cea9 100644 --- a/src/user/index.js +++ b/src/user/index.js @@ -8,6 +8,7 @@ const db = require('../database'); const privileges = require('../privileges'); const categories = require('../categories'); const meta = require('../meta'); +const activitypub = require('../activitypub'); const utils = require('../utils'); const User = module.exports; @@ -109,6 +110,12 @@ User.getUidByUserslug = async function (userslug) { if (!userslug) { return 0; } + + if (userslug.includes('@')) { + await activitypub.actors.assert(userslug); + return (await db.getObjectField('handle:uid', String(userslug).toLowerCase())) || null; + } + return await db.sortedSetScore('userslug:uid', userslug); }; diff --git a/src/user/online.js b/src/user/online.js index 94da8f6bc3f6..23a1156ff229 100644 --- a/src/user/online.js +++ b/src/user/online.js @@ -4,10 +4,11 @@ const db = require('../database'); const topics = require('../topics'); const plugins = require('../plugins'); const meta = require('../meta'); +const utils = require('../utils'); module.exports = function (User) { User.updateLastOnlineTime = async function (uid) { - if (!(parseInt(uid, 10) > 0)) { + if (utils.isNumber(uid) && !(parseInt(uid, 10) > 0)) { return; } const userData = await db.getObjectFields(`user:${uid}`, ['userslug', 'status', 'lastonline']); diff --git a/src/user/posts.js b/src/user/posts.js index 318718f1c05c..418b8979920c 100644 --- a/src/user/posts.js +++ b/src/user/posts.js @@ -5,6 +5,7 @@ const meta = require('../meta'); const privileges = require('../privileges'); const plugins = require('../plugins'); const groups = require('../groups'); +const activitypub = require('../activitypub'); module.exports = function (User) { User.isReadyToPost = async function (uid, cid) { @@ -30,7 +31,7 @@ module.exports = function (User) { }; async function isReady(uid, cid, field) { - if (parseInt(uid, 10) === 0) { + if (activitypub.helpers.isUri(uid) || parseInt(uid, 10) === 0) { return; } const [userData, isAdminOrMod, isMemberOfExempt] = await Promise.all([ diff --git a/src/user/profile.js b/src/user/profile.js index 9d65037bbed9..e8c654f0cb3f 100644 --- a/src/user/profile.js +++ b/src/user/profile.js @@ -11,6 +11,7 @@ const meta = require('../meta'); const db = require('../database'); const groups = require('../groups'); const plugins = require('../plugins'); +const api = require('../api'); module.exports = function (User) { User.updateProfile = async function (uid, data, extraFields) { @@ -65,6 +66,7 @@ module.exports = function (User) { fields: fields, oldData: oldData, }); + api.activitypub.update.profile({ uid }, { uid: updateUid }); return await User.getUserFields(updateUid, [ 'email', 'username', 'userslug', diff --git a/src/user/search.js b/src/user/search.js index ec0b81d0253d..252273b941ac 100644 --- a/src/user/search.js +++ b/src/user/search.js @@ -7,6 +7,7 @@ const meta = require('../meta'); const plugins = require('../plugins'); const db = require('../database'); const groups = require('../groups'); +const activitypub = require('../activitypub'); const utils = require('../utils'); module.exports = function (User) { @@ -40,11 +41,42 @@ module.exports = function (User) { } else if (searchBy === 'uid') { uids = [query]; } else { - const searchMethod = data.findUids || findUids; - uids = await searchMethod(query, searchBy, data.hardCap); + if (!data.findUids && data.uid) { + const handle = activitypub.helpers.isWebfinger(data.query); + if (handle || activitypub.helpers.isUri(data.query)) { + const local = await activitypub.helpers.resolveLocalId(data.query); + if (local.type === 'user') { + uids = [local.id]; + } else { + const assertion = await activitypub.actors.assert([handle || data.query]); + if (assertion === true) { + uids = [handle ? await User.getUidByUserslug(handle) : query]; + } else if (Array.isArray(assertion) && assertion.length) { + uids = assertion.map(u => u.id); + } + } + } + } + + if (!uids.length) { + const searchMethod = data.findUids || findUids; + uids = await searchMethod(query, searchBy, data.hardCap); + + const mapping = { + username: 'ap.preferredUsername', + fullname: 'ap.name', + }; + if (meta.config.activitypubEnabled && mapping.hasOwnProperty(searchBy)) { + uids = uids.concat(await searchMethod(query, mapping[searchBy], data.hardCap)); + } + } } uids = await filterAndSortUids(uids, data); + if (data.hardCap > 0) { + uids.length = data.hardCap; + } + const result = await plugins.hooks.fire('filter:users.search', { uids: uids, uid: uid }); uids = result.uids; @@ -74,7 +106,8 @@ module.exports = function (User) { } searchResult.timing = (process.elapsedTimeSince(startTime) / 1000).toFixed(2); - searchResult.users = userData.filter(user => user && user.uid > 0); + searchResult.users = userData.filter(user => (user && + utils.isNumber(user.uid) ? user.uid > 0 : activitypub.helpers.isUri(user.uid))); return searchResult; }; @@ -90,12 +123,19 @@ module.exports = function (User) { hardCap = hardCap || resultsPerPage * 10; const data = await db.getSortedSetRangeByLex(`${searchBy}:sorted`, min, max, 0, hardCap); - const uids = data.map(data => data.split(':').pop()); + // const uids = data.map(data => data.split(':').pop()); + const uids = data.map((data) => { + if (data.includes(':https:')) { + return data.substring(data.indexOf(':https:') + 1); + } + + return data.split(':').pop(); + }); return uids; } async function filterAndSortUids(uids, data) { - uids = uids.filter(uid => parseInt(uid, 10)); + uids = uids.filter(uid => parseInt(uid, 10) || activitypub.helpers.isUri(uid)); let filters = data.filters || []; filters = Array.isArray(filters) ? filters : [data.filters]; const fields = []; diff --git a/src/user/settings.js b/src/user/settings.js index d85a712ba621..5390f37580da 100644 --- a/src/user/settings.js +++ b/src/user/settings.js @@ -5,6 +5,7 @@ const validator = require('validator'); const meta = require('../meta'); const db = require('../database'); +const activitypub = require('../activitypub'); const plugins = require('../plugins'); const notifications = require('../notifications'); const languages = require('../languages'); @@ -16,6 +17,10 @@ module.exports = function (User) { postsPerPage: 20, topicsPerPage: 20, }; + const remoteDefaultSettings = Object.freeze({ + categoryWatchState: 'notwatching', + }); + User.getSettings = async function (uid) { if (parseInt(uid, 10) <= 0) { const isSpider = parseInt(uid, 10) === -1; @@ -90,6 +95,8 @@ module.exports = function (User) { function getSetting(settings, key, defaultValue) { if (settings[key] || settings[key] === 0) { return settings[key]; + } else if (activitypub.helpers.isUri(settings.uid) && remoteDefaultSettings[key]) { + return remoteDefaultSettings[key]; } else if (meta.config[key] || meta.config[key] === 0) { return meta.config[key]; } diff --git a/src/views/admin/manage/category-federation.tpl b/src/views/admin/manage/category-federation.tpl new file mode 100644 index 000000000000..961dda532ccd --- /dev/null +++ b/src/views/admin/manage/category-federation.tpl @@ -0,0 +1,67 @@ + +
+ +
+
+

[[admin/manage/categories:federation.title, {name}]]

+ +
+
+ + {{{ if !enabled }}} +
+

[[admin/manage/categories:federation.disabled]]

+ [[admin/manage/categories:federation.disabled-cta]] +
+ {{{ else }}} +
+
+
+
+
+
[[admin/manage/categories:federation.syncing-header]]
+

[[admin/manage/categories:federation.syncing-intro]]

+

[[admin/manage/categories:federation.syncing-caveat]]

+ + {{{ if !following.length }}} +
[[admin/manage/categories:federation.syncing-none]]
+ {{{ else }}} + + + + + + + + + {{{ each following }}} + + + + + {{{ end }}} + +
[[admin/manage/categories:federation.syncing-actorUri]]
+
{./id}
+ {{{ if !./approved }}} + Pending + {{{ end }}} +
+ +
+ {{{ end }}} + +
+ +
+ + +
+
+
+
+
+
+
+ {{{ end }}} +
\ No newline at end of file diff --git a/src/views/admin/manage/category.tpl b/src/views/admin/manage/category.tpl index fb2afcedec74..bf8272d390af 100644 --- a/src/views/admin/manage/category.tpl +++ b/src/views/admin/manage/category.tpl @@ -19,6 +19,16 @@ +
+ + +

+ [[admin/manage/categories:handle.help]] +

+
+
- {function.spawnPrivilegeStates, privileges.groups.name, ../privileges, ../types} + {function.spawnPrivilegeStates, cid, privileges.groups.name, ../privileges, ../types} {{{ end }}} @@ -133,7 +133,7 @@ - {function.spawnPrivilegeStates, privileges.users.username, ../privileges, ../types} + {function.spawnPrivilegeStates, cid, privileges.users.username, ../privileges, ../types} {{{ end }}} diff --git a/src/views/admin/partials/privileges/global.tpl b/src/views/admin/partials/privileges/global.tpl index 1bff0786bb29..778afa869912 100644 --- a/src/views/admin/partials/privileges/global.tpl +++ b/src/views/admin/partials/privileges/global.tpl @@ -41,7 +41,7 @@ - {function.spawnPrivilegeStates, privileges.groups.name, ../privileges, ../types} + {function.spawnPrivilegeStates, cid, privileges.groups.name, ../privileges, ../types} {{{ end }}} @@ -104,7 +104,7 @@ - {function.spawnPrivilegeStates, privileges.users.username, ../privileges, ../types} + {function.spawnPrivilegeStates, cid, privileges.users.username, ../privileges, ../types} {{{ end }}} diff --git a/src/views/admin/settings/activitypub.tpl b/src/views/admin/settings/activitypub.tpl new file mode 100644 index 000000000000..5ca5a6d8749c --- /dev/null +++ b/src/views/admin/settings/activitypub.tpl @@ -0,0 +1,45 @@ +
+ + +

[[admin/settings/activitypub:intro-lead]]

+

[[admin/settings/activitypub:intro-body]]

+ +
+ +
+
[[admin/settings/activitypub:general]]
+
+
+
+ + +

[[admin/settings/activitypub:enabled-help]]

+
+
+ + +

[[admin/settings/activitypub:allowLoopback-help]]

+
+
+
+
+ +
+
[[admin/settings/activitypub:server-filtering]]
+
+
+
+

[[admin/settings/activitypub:server.filter-help]]

+

[[admin/settings/activitypub:count, 0]]

+

This feature is not available yet

+ + +
+
+ + +
+
+
+
+
diff --git a/src/views/admin/settings/navigation.tpl b/src/views/admin/settings/navigation.tpl index 6af7e605116f..cbcc40ee1966 100644 --- a/src/views/admin/settings/navigation.tpl +++ b/src/views/admin/settings/navigation.tpl @@ -122,8 +122,8 @@
[[admin/settings/navigation:available-menu-items]]
diff --git a/src/views/modals/flag.tpl b/src/views/modals/flag.tpl index 3b101138e422..c49c5e76b7a9 100644 --- a/src/views/modals/flag.tpl +++ b/src/views/modals/flag.tpl @@ -31,9 +31,17 @@
-
+
+ {{{ if remote }}} +
+ + +
+ {{{ end }}}
diff --git a/src/webserver.js b/src/webserver.js index ff5031ff4136..1b6d62d75202 100644 --- a/src/webserver.js +++ b/src/webserver.js @@ -229,7 +229,13 @@ function configureBodyParser(app) { } app.use(bodyParser.urlencoded(urlencodedOpts)); - const jsonOpts = nconf.get('bodyParser:json') || {}; + const jsonOpts = nconf.get('bodyParser:json') || { + type: [ + 'application/json', + 'application/ld+json', + 'application/activity+json', + ], + }; app.use(bodyParser.json(jsonOpts)); } diff --git a/test/activitypub.js b/test/activitypub.js new file mode 100644 index 000000000000..89635a6f96a4 --- /dev/null +++ b/test/activitypub.js @@ -0,0 +1,548 @@ +'use strict'; + +const assert = require('assert'); +const nconf = require('nconf'); +const path = require('path'); + +const db = require('./mocks/databasemock'); +const slugify = require('../src/slugify'); +const utils = require('../src/utils'); +const request = require('../src/request'); + +const file = require('../src/file'); +const install = require('../src/install'); +const meta = require('../src/meta'); +const user = require('../src/user'); +const categories = require('../src/categories'); +const topics = require('../src/topics'); +const posts = require('../src/posts'); +const activitypub = require('../src/activitypub'); + +describe('ActivityPub integration', () => { + before(async () => { + meta.config.activitypubEnabled = 1; + await install.giveWorldPrivileges(); + }); + + after(() => { + delete meta.config.activitypubEnabled; + }); + + describe('Helpers', () => { + describe('.query()', () => { + + }); + + describe('.generateKeys()', () => { + + }); + + describe('.resolveId()', () => { + let url; + let resolved; + + before(() => { + url = 'https://example.org/topic/foobar'; + resolved = 'https://example.org/tid/1234'; + activitypub._cache.set(`0;${url}`, { + id: resolved, + }); + }); + + it('should return the resolved id when queried', async () => { + const id = await activitypub.resolveId(0, url); + assert.strictEqual(id, resolved); + }); + + it('should return null when the query fails', async () => { + const id = await activitypub.resolveId(0, 'https://example.org/sdlknsdfnsd'); + assert.strictEqual(id, null); + }); + + it('should return null when the resolved host does not match the queried host', async () => { + const url = 'https://example.com/topic/foobar'; // .com attempting to overwrite .org data + const resolved = 'https://example.org/tid/1234'; // .org + activitypub._cache.set(`0;${url}`, { + id: resolved, + }); + + const id = await activitypub.resolveId(0, url); + assert.strictEqual(id, null); + }); + }); + + describe('.resolveLocalId()', () => { + let uid; + let slug; + + beforeEach(async () => { + slug = slugify(utils.generateUUID().slice(0, 8)); + uid = await user.create({ username: slug }); + }); + + it('should return null when an invalid input is passed in', async () => { + const { type, id } = await activitypub.helpers.resolveLocalId('ncl28h3qwhoiclwnevoinw3u'); + assert.strictEqual(type, null); + assert.strictEqual(id, null); + }); + + it('should return null when valid input is passed but does not resolve', async () => { + const { type, id } = await activitypub.helpers.resolveLocalId(`acct%3afoobar@${nconf.get('url_parsed').host}`); + assert.strictEqual(type, 'user'); + assert.strictEqual(id, null); + }); + + it('should resolve to a local uid when given a webfinger-style string', async () => { + const { id } = await activitypub.helpers.resolveLocalId(`acct%3a${slug}@${nconf.get('url_parsed').host}`); + assert.strictEqual(id, uid); + }); + + it('should resolve even without the "acct:" prefix', async () => { + const { id } = await activitypub.helpers.resolveLocalId(`${slug}@${nconf.get('url_parsed').host}`); + assert.strictEqual(id, uid); + }); + + it('should resolve when passed a full URL', async () => { + const { id } = await activitypub.helpers.resolveLocalId(`${nconf.get('url')}/user/${slug}`); + assert.strictEqual(id, uid); + }); + }); + + describe('.generateTitle', () => { + it('should take the first paragraph element\'s text', () => { + const source = '

Lorem ipsum dolor sit amet

consectetur adipiscing elit. Integer tincidunt metus scelerisque, dignissim risus a, fermentum leo. Pellentesque eleifend ullamcorper risus tempus vestibulum. Proin mollis ipsum et magna lobortis, at pretium enim pharetra. Ut vel ex metus. Mauris faucibus lectus et nulla iaculis, et pellentesque elit pellentesque. Aliquam rhoncus nec nulla eu lacinia. Maecenas cursus iaculis ligula, eu pharetra ex suscipit sit amet.

'; + const title = activitypub.helpers.generateTitle(source); + assert.strictEqual(title, 'Lorem ipsum dolor sit amet'); + }); + + it('should take the first line\'s text if no matched elements', () => { + const source = 'Lorem ipsum dolor sit amet\n\nconsectetur adipiscing elit. Integer tincidunt metus scelerisque, dignissim risus a, fermentum leo. Pellentesque eleifend ullamcorper risus tempus vestibulum. Proin mollis ipsum et magna lobortis, at pretium enim pharetra. Ut vel ex metus. Mauris faucibus lectus et nulla iaculis, et pellentesque elit pellentesque. Aliquam rhoncus nec nulla eu lacinia. Maecenas cursus iaculis ligula, eu pharetra ex suscipit sit amet.'; + const title = activitypub.helpers.generateTitle(source); + assert.strictEqual(title, 'Lorem ipsum dolor sit amet'); + }); + + it('should trim down the title if it is too long per settings', () => { + const value = meta.config.maximumTitleLength; + meta.config.maximumTitleLength = 10; + const source = '@@@@@@@@@@@@@@@@@@@@'; + const title = activitypub.helpers.generateTitle(source); + assert.strictEqual(title, '@@@@@@@...'); + meta.config.maximumTitleLength = value; + }); + + it('should take the first sentence of a matched element/line', () => { + const source = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam a ex pellentesque, fringilla lorem non, blandit est. Nulla facilisi. Curabitur cursus neque vel enim semper, id lacinia elit facilisis. Vestibulum turpis orci, efficitur ut semper eu, faucibus eu turpis. Praesent eu odio non libero gravida tempor. Ut porta pellentesque orci. In porta nunc eget tincidunt interdum. Curabitur vel dui nec libero tempus porttitor. Phasellus tincidunt, diam id viverra suscipit, est diam maximus purus, in vestibulum dui ligula vel libero. Sed tempus finibus ante, sit amet consequat magna facilisis eget. Proin ullamcorper, velit sit amet feugiat varius, massa sem aliquam dui, non aliquam augue velit vel est. Phasellus eu sapien in purus feugiat scelerisque congue id velit. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.'; + const title = activitypub.helpers.generateTitle(source); + assert.strictEqual(title, 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.'); + }); + + it('should also consider other sentence ending symbols', () => { + const source = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit? Etiam a ex pellentesque, fringilla lorem non, blandit est. Nulla facilisi. Curabitur cursus neque vel enim semper, id lacinia elit facilisis. Vestibulum turpis orci, efficitur ut semper eu, faucibus eu turpis. Praesent eu odio non libero gravida tempor. Ut porta pellentesque orci. In porta nunc eget tincidunt interdum. Curabitur vel dui nec libero tempus porttitor. Phasellus tincidunt, diam id viverra suscipit, est diam maximus purus, in vestibulum dui ligula vel libero. Sed tempus finibus ante, sit amet consequat magna facilisis eget. Proin ullamcorper, velit sit amet feugiat varius, massa sem aliquam dui, non aliquam augue velit vel est. Phasellus eu sapien in purus feugiat scelerisque congue id velit. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.'; + const title = activitypub.helpers.generateTitle(source); + assert.strictEqual(title, 'Lorem ipsum dolor sit amet, consectetur adipiscing elit?'); + }); + }); + }); + + describe('ActivityPub screener middleware', () => { + let uid; + + beforeEach(async () => { + uid = await user.create({ username: slugify(utils.generateUUID().slice(0, 8)) }); + }); + + it('should return regular user profile html if federation is disabled', async () => { + delete meta.config.activitypubEnabled; + + const { response, body } = await request.get(`${nconf.get('url')}/uid/${uid}`, { + headers: { + Accept: 'text/html', + }, + }); + + assert(response); + assert.strictEqual(response.statusCode, 200); + assert(body.startsWith('')); + + meta.config.activitypubEnabled = 1; + }); + + it('should return regular user profile html if Accept header is not ActivityPub-related', async () => { + const { response, body } = await request.get(`${nconf.get('url')}/uid/${uid}`, { + headers: { + Accept: 'text/html', + }, + }); + + assert(response); + assert.strictEqual(response.statusCode, 200); + assert(body.startsWith('')); + }); + + it('should return the ActivityPub Actor JSON-LD payload if the correct Accept header is provided', async () => { + const { response, body } = await request.get(`${nconf.get('url')}/uid/${uid}`, { + headers: { + Accept: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', + }, + }); + + assert(response); + assert.strictEqual(response.statusCode, 200); + assert(body.hasOwnProperty('@context')); + assert(body['@context'].includes('https://www.w3.org/ns/activitystreams')); + }); + }); + + describe('User Actor endpoint', () => { + let uid; + let slug; + + beforeEach(async () => { + slug = slugify(utils.generateUUID().slice(0, 8)); + uid = await user.create({ username: slug }); + }); + + it('should return a valid ActivityPub Actor JSON-LD payload', async () => { + const { response, body } = await request.get(`${nconf.get('url')}/uid/${uid}`, { + headers: { + Accept: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', + }, + }); + + assert(response); + assert.strictEqual(response.statusCode, 200); + assert(body.hasOwnProperty('@context')); + assert(body['@context'].includes('https://www.w3.org/ns/activitystreams')); + + ['id', 'url', 'followers', 'following', 'inbox', 'outbox'].forEach((prop) => { + assert(body.hasOwnProperty(prop)); + assert(body[prop]); + }); + + assert.strictEqual(body.id, `${nconf.get('url')}/uid/${uid}`); + assert.strictEqual(body.type, 'Person'); + }); + + it('should contain a `publicKey` property with a public key', async () => { + const { response, body } = await request.get(`${nconf.get('url')}/uid/${uid}`, { + headers: { + Accept: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', + }, + }); + + assert(body.hasOwnProperty('publicKey')); + assert(['id', 'owner', 'publicKeyPem'].every(prop => body.publicKey.hasOwnProperty(prop))); + }); + }); + + describe('Instance Actor endpoint', () => { + let response; + let body; + + before(async () => { + ({ response, body } = await request.get(`${nconf.get('url')}/actor`, { + headers: { + Accept: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', + }, + })); + }); + + it('should respond properly', async () => { + assert(response); + assert.strictEqual(response.statusCode, 200); + }); + + it('should return a valid ActivityPub Actor JSON-LD payload', async () => { + assert(body.hasOwnProperty('@context')); + assert(body['@context'].includes('https://www.w3.org/ns/activitystreams')); + + ['id', 'url', 'inbox', 'outbox', 'name', 'preferredUsername'].forEach((prop) => { + assert(body.hasOwnProperty(prop)); + assert(body[prop]); + }); + + assert.strictEqual(body.id, body.url); + assert.strictEqual(body.type, 'Application'); + assert.strictEqual(body.name, meta.config.site_title || 'NodeBB'); + assert.strictEqual(body.preferredUsername, nconf.get('url_parsed').hostname); + }); + + it('should contain a `publicKey` property with a public key', async () => { + assert(body.hasOwnProperty('publicKey')); + assert(['id', 'owner', 'publicKeyPem'].every(prop => body.publicKey.hasOwnProperty(prop))); + }); + + it('should also have a valid WebFinger response tied to `preferredUsername`', async () => { + const { response, body: body2 } = await request.get(`${nconf.get('url')}/.well-known/webfinger?resource=acct%3a${body.preferredUsername}@${nconf.get('url_parsed').host}`); + + assert.strictEqual(response.statusCode, 200); + assert(body2 && body2.aliases && body2.links); + assert(body2.aliases.includes(nconf.get('url'))); + assert(body2.links.some(item => item.rel === 'self' && item.type === 'application/activity+json' && item.href === `${nconf.get('url')}/actor`)); + }); + }); + + describe('Receipt of ActivityPub events to inboxes (federating IN)', () => { + describe('Create', () => { + describe('Note', () => { + const slug = utils.generateUUID(); + const id = `https://example.org/status/${slug}`; + const remoteNote = { + '@context': 'https://www.w3.org/ns/activitystreams', + id, + url: id, + type: 'Note', + to: ['https://www.w3.org/ns/activitystreams#Public'], + cc: ['https://example.org/user/foobar/followers'], + inReplyTo: null, + attributedTo: 'https://example.org/user/foobar', + name: 'Foo Bar', + content: 'Baz quux', + published: new Date().toISOString(), + source: { + content: '**Baz quux**', + mediaType: 'text/markdown', + }, + }; + const remoteUser = { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'https://example.org/user/foobar', + url: 'https://example.org/user/foobar', + + type: 'Person', + name: 'Foo Bar', + preferredUsername: 'foobar', + publicKey: { + id: 'https://example.org/user/foobar#key', + owner: 'https://example.org/user/foobar', + publicKeyPem: 'publickey', + }, + }; + + let topic; + + before(async () => { + const controllers = require('../src/controllers'); + + activitypub._cache.set(`0;${id}`, remoteNote); + activitypub._cache.set(`0;https://example.org/user/foobar`, remoteUser); + await db.sortedSetAdd(`followersRemote:${remoteUser.id}`, Date.now(), 1); // fake a follow + await controllers.activitypub.postInbox({ + body: { + type: 'Create', + actor: 'https://example.org/user/foobar', + object: remoteNote, + }, + }, { sendStatus: () => {} }); + }); + + it('should create a new topic if Note is at root-level or its parent has not been seen before', async () => { + const saved = await db.getObject(`post:${id}`); + + assert(saved); + assert(saved.tid); + + topic = await topics.getTopicData(saved.tid); + assert(topic); + assert.strictEqual(saved.uid, 'https://example.org/user/foobar'); + assert.strictEqual(saved.content, 'Baz quux'); + }); + + it('should properly save the topic title in the topic hash', async () => { + assert.strictEqual(topic.title, 'Foo Bar'); + }); + + it('should properly save the mainPid in the topic hash', async () => { + assert.strictEqual(topic.mainPid, id); + }); + + // todo: test topic replies, too + }); + }); + }); + + describe('Serving of local assets to remote clients (mocking)', () => { + describe('Note', () => { + let cid; + let uid; + + before(async () => { + ({ cid } = await categories.create({ name: utils.generateUUID().slice(0, 8) })); + const slug = slugify(utils.generateUUID().slice(0, 8)); + uid = await user.create({ username: slug }); + }); + + describe('Existing and resolvable', () => { + let body; + let response; + let postData; + + before(async () => { + ({ postData } = await topics.post({ + uid, + cid, + title: 'Lorem "Lipsum" Ipsum', + content: 'Lorem ipsum dolor sit amet', + })); + + ({ body, response } = await request.get(`${nconf.get('url')}/post/${postData.pid}`, { + headers: { + Accept: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', + }, + })); + }); + + it('should return a 404 on a non-existant post', async () => { + const { response } = await request.get(`${nconf.get('url')}/post/${parseInt(postData.pid, 10) + 1}`, { + headers: { + Accept: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', + }, + }); + + assert.strictEqual(response.statusCode, 404); + }); + + it('should return a 200 response on an existing post', () => { + assert.strictEqual(response.statusCode, 200); + }); + + it('should return the expected Content-Type header', () => { + assert.strictEqual(response.headers['content-type'], 'application/activity+json; charset=utf-8'); + }); + + it('Topic title (`name`) should not be escaped', () => { + assert.strictEqual(body.name, 'Lorem "Lipsum" Ipsum'); + }); + }); + + describe('Soft deleted', () => { + let body; + let response; + let postData; + + before(async () => { + ({ postData } = await topics.post({ + uid, + cid, + title: utils.generateUUID(), + content: utils.generateUUID(), + })); + + await posts.delete(postData.pid, uid); + + ({ body, response } = await request.get(`${nconf.get('url')}/post/${postData.pid}`, { + headers: { + Accept: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', + }, + })); + }); + + it('should return a 200 response on an existing post', () => { + assert.strictEqual(response.statusCode, 200); + }); + + it('should return a Tombstone object', () => { + assert.strictEqual(body.type, 'Tombstone'); + }); + + it('should still retain the existing id and former type', () => { + assert.strictEqual(body.id, `${nconf.get('url')}/post/${postData.pid}`); + assert.strictEqual(body.formerType, 'Note'); + }); + + it('should still contain contextual information (context, audience, attributedTo)', () => { + assert(['context', 'audience', 'attributedTo'].every(prop => body.hasOwnProperty(prop) && body[prop])); + }); + }); + }); + }); + + describe('Actor asserton', () => { + describe('happy path', () => { + let uid; + let actorUri; + + before(async () => { + uid = utils.generateUUID().slice(0, 8); + actorUri = `https://example.org/user/${uid}`; + activitypub._cache.set(`0;${actorUri}`, { + '@context': 'https://www.w3.org/ns/activitystreams', + id: actorUri, + url: actorUri, + + type: 'Person', + name: 'example', + preferredUsername: 'example', + + publicKey: { + id: `${actorUri}#key`, + owner: actorUri, + publicKeyPem: 'somekey', + }, + }); + }); + + it('should return true if successfully asserted', async () => { + const result = await activitypub.actors.assert([actorUri]); + assert(result); + }); + + it('should contain a representation of that remote user in the database', async () => { + const exists = await db.exists(`userRemote:${actorUri}`); + assert(exists); + + const userData = await user.getUserData(actorUri); + assert(userData); + assert.strictEqual(userData.uid, actorUri); + }); + + it('should save the actor\'s publicly accessible URL in the hash as well', async () => { + const url = await user.getUserField(actorUri, 'url'); + assert.strictEqual(url, actorUri); + }); + }); + + describe('edge case: loopback handles and uris', () => { + let uid; + const userslug = utils.generateUUID().slice(0, 8); + before(async () => { + uid = await user.create({ username: userslug }); + }); + + it('should return true but not actually assert the handle into the database', async () => { + const handle = `${userslug}@${nconf.get('url_parsed').host}`; + const result = await activitypub.actors.assert([handle]); + assert(result); + + const handleExists = await db.isObjectField('handle:uid', handle); + assert.strictEqual(handleExists, false); + + const userRemoteHashExists = await db.exists(`userRemote:${nconf.get('url')}/uid/${uid}`); + assert.strictEqual(userRemoteHashExists, false); + }); + + it('should return true but not actually assert the uri into the database', async () => { + const uri = `${nconf.get('url')}/uid/${uid}`; + const result = await activitypub.actors.assert([uri]); + assert(result); + + const userRemoteHashExists = await db.exists(`userRemote:${uri}`); + assert.strictEqual(userRemoteHashExists, false); + }); + }); + }); + + describe('ActivityPub', async () => { + let files; + + before(async () => { + files = await file.walk(path.resolve(__dirname, './activitypub')); + }); + + it('subfolder tests', () => { + files.forEach((filePath) => { + require(filePath); + }); + }); + }); +}); diff --git a/test/activitypub/analytics.js b/test/activitypub/analytics.js new file mode 100644 index 000000000000..6d68b6f165bf --- /dev/null +++ b/test/activitypub/analytics.js @@ -0,0 +1,154 @@ +'use strict'; + +const nconf = require('nconf'); +const assert = require('assert'); + +const db = require('../../src/database'); +const controllers = require('../../src/controllers'); +const middleware = require('../../src/middleware'); +const activitypub = require('../../src/activitypub'); +const utils = require('../../src/utils'); +const user = require('../../src/user'); +const categories = require('../../src/categories'); +const topics = require('../../src/topics'); +const analytics = require('../../src/analytics'); +const api = require('../../src/api'); + +describe('Analytics', () => { + let cid; + let uid; + let postData; + + before(async () => { + nconf.set('runJobs', 1); + ({ cid } = await categories.create({ name: utils.generateUUID().slice(0, 8) })); + const remoteUser = { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'https://example.org/user/foobar', + url: 'https://example.org/user/foobar', + + type: 'Person', + name: 'Foo Bar', + preferredUsername: 'foobar', + publicKey: { + id: 'https://example.org/user/foobar#key', + owner: 'https://example.org/user/foobar', + publicKeyPem: 'publickey', + }, + }; + activitypub._cache.set(`0;https://example.org/user/foobar`, remoteUser); + }); + + after(async () => { + nconf.set('runJobs', undefined); + }); + + beforeEach(async () => { + uid = await user.create({ username: utils.generateUUID().slice(0, 8) }); + ({ postData } = await topics.post({ + uid, + cid, + title: utils.generateUUID(), + content: utils.generateUUID(), + })); + }); + + it('should record the incoming activity if successfully processed', async () => { + const id = `https://example.org/activity/${utils.generateUUID()}`; + await controllers.activitypub.postInbox({ + body: { + id, + type: 'Like', + actor: 'https://example.org/user/foobar', + object: { + type: 'Note', + id: `${nconf.get('url')}/post/${postData.pid}`, + }, + }, + }, { sendStatus: () => {} }); + const processed = await db.isSortedSetMember('activities:datetime', id); + + assert(processed); + }); + + it('should not process the activity if received again', async () => { + // Specifically, the controller would update the score, but the request should be caught in middlewares and ignored + const id = `https://example.org/activity/${utils.generateUUID()}`; + await controllers.activitypub.postInbox({ + body: { + id, + type: 'Like', + actor: 'https://example.org/user/foobar', + object: { + type: 'Note', + id: `${nconf.get('url')}/post/${postData.pid}`, + }, + }, + }, { sendStatus: () => {} }); + + await middleware.activitypub.validate({ + body: { + id, + type: 'Like', + actor: 'https://example.org/user/foobar', + object: { + type: 'Note', + id: `${nconf.get('url')}/post/${postData.pid}`, + }, + }, + }, { + sendStatus: (statusCode) => { + assert.strictEqual(statusCode, 200); + }, + }); + }); + + it('should increment the last seen time of that domain', async () => { + const id = `https://example.org/activity/${utils.generateUUID()}`; + const before = await db.sortedSetScore('domains:lastSeen', 'example.org'); + await controllers.activitypub.postInbox({ + body: { + id, + type: 'Like', + actor: 'https://example.org/user/foobar', + object: { + type: 'Note', + id: `${nconf.get('url')}/post/${postData.pid}`, + }, + }, + }, { sendStatus: () => {} }); + + const after = await db.sortedSetScore('domains:lastSeen', 'example.org'); + + assert(before && after); + assert(before < after); + }); + + it('should increment various metrics', async () => { + let counters; + ({ counters } = analytics.peek()); + const before = { ...counters }; + + const id = `https://example.org/activity/${utils.generateUUID()}`; + await controllers.activitypub.postInbox({ + body: { + id, + type: 'Like', + actor: 'https://example.org/user/foobar', + object: { + type: 'Note', + id: `${nconf.get('url')}/post/${postData.pid}`, + }, + }, + }, { sendStatus: () => {} }); + + ({ counters } = analytics.peek()); + const after = { ...counters }; + + const metrics = ['activities', 'activities:byType:Like', 'activities:byHost:example.org']; + metrics.forEach((metric) => { + assert(before[metric] && after[metric]); + assert(before[metric] < after[metric]); + }); + }); +}); diff --git a/test/activitypub/notes.js b/test/activitypub/notes.js new file mode 100644 index 000000000000..90b95a9aa9c4 --- /dev/null +++ b/test/activitypub/notes.js @@ -0,0 +1,107 @@ +'use strict'; + +const assert = require('assert'); + +const db = require('../../src/database'); +const user = require('../../src/user'); +const categories = require('../../src/categories'); +const topics = require('../../src/topics'); +const activitypub = require('../../src/activitypub'); +const utils = require('../../src/utils'); + +describe('Notes', () => { + describe('Inbox Synchronization', () => { + let cid; + let uid; + let topicData; + + before(async () => { + ({ cid } = await categories.create({ name: utils.generateUUID().slice(0, 8) })); + }); + + beforeEach(async () => { + uid = await user.create({ username: utils.generateUUID().slice(0, 10) }); + ({ topicData } = await topics.post({ + cid, + uid, + title: utils.generateUUID(), + content: utils.generateUUID(), + })); + }); + + it('should add a topic to a user\'s inbox if user is a recipient in OP', async () => { + await db.setAdd(`post:${topicData.mainPid}:recipients`, [uid]); + await activitypub.notes.syncUserInboxes(topicData.tid); + const inboxed = await db.isSortedSetMember(`uid:${uid}:inbox`, topicData.tid); + + assert.strictEqual(inboxed, true); + }); + + it('should add a topic to a user\'s inbox if a user is a recipient in a reply', async () => { + const uid = await user.create({ username: utils.generateUUID().slice(0, 10) }); + const { pid } = await topics.reply({ + tid: topicData.tid, + uid, + content: utils.generateUUID(), + }); + await db.setAdd(`post:${pid}:recipients`, [uid]); + await activitypub.notes.syncUserInboxes(topicData.tid); + const inboxed = await db.isSortedSetMember(`uid:${uid}:inbox`, topicData.tid); + + assert.strictEqual(inboxed, true); + }); + + it('should maintain a list of recipients at the topic level', async () => { + await db.setAdd(`post:${topicData.mainPid}:recipients`, [uid]); + await activitypub.notes.syncUserInboxes(topicData.tid); + const [isRecipient, count] = await Promise.all([ + db.isSetMember(`tid:${topicData.tid}:recipients`, uid), + db.setCount(`tid:${topicData.tid}:recipients`), + ]); + + assert(isRecipient); + assert.strictEqual(count, 1); + }); + + it('should add topic to a user\'s inbox if it is explicitly passed in as an argument', async () => { + await activitypub.notes.syncUserInboxes(topicData.tid, uid); + const inboxed = await db.isSortedSetMember(`uid:${uid}:inbox`, topicData.tid); + + assert.strictEqual(inboxed, true); + }); + }); + + describe('Deletion', () => { + let cid; + let uid; + let topicData; + + before(async () => { + ({ cid } = await categories.create({ name: utils.generateUUID().slice(0, 8) })); + }); + + beforeEach(async () => { + uid = await user.create({ username: utils.generateUUID().slice(0, 10) }); + ({ topicData } = await topics.post({ + cid, + uid, + title: utils.generateUUID(), + content: utils.generateUUID(), + })); + }); + + it('should clean up recipient sets for the post', async () => { + const { pid } = await topics.reply({ + pid: `https://example.org/${utils.generateUUID().slice(0, 8)}`, + tid: topicData.tid, + uid, + content: utils.generateUUID(), + }); + await db.setAdd(`post:${pid}:recipients`, [uid]); + await activitypub.notes.delete([pid]); + + const inboxed = await db.isSetMember(`post:${pid}:recipients`, uid); + assert(!inboxed); + }); + }); +}); diff --git a/test/activitypub/signatures.js b/test/activitypub/signatures.js new file mode 100644 index 000000000000..c05b24140b84 --- /dev/null +++ b/test/activitypub/signatures.js @@ -0,0 +1,143 @@ +'use strict'; + +const assert = require('assert'); +const nconf = require('nconf'); +const { createHash } = require('crypto'); + +const user = require('../../src/user'); +const utils = require('../../src/utils'); +const db = require('../../src/database'); +const activitypub = require('../../src/activitypub'); + +describe('http signature signing and verification', () => { + describe('.sign()', () => { + let uid; + + before(async () => { + uid = await user.create({ username: utils.generateUUID().slice(0, 10) }); + }); + + it('should create a key-pair for a user if the user does not have one already', async () => { + const endpoint = `${nconf.get('url')}/uid/${uid}/inbox`; + const keyData = await activitypub.getPrivateKey('uid', uid); + await activitypub.sign(keyData, endpoint); + const { publicKey, privateKey } = await db.getObject(`uid:${uid}:keys`); + + assert(publicKey); + assert(privateKey); + }); + + it('should return an object with date, a null digest, and signature, if no payload is passed in', async () => { + const endpoint = `${nconf.get('url')}/uid/${uid}/inbox`; + const keyData = await activitypub.getPrivateKey('uid', uid); + const { date, digest, signature } = await activitypub.sign(keyData, endpoint); + const dateObj = new Date(date); + + assert(signature); + assert(dateObj); + assert.strictEqual(digest, null); + }); + + it('should also return a digest hash if payload is passed in', async () => { + const endpoint = `${nconf.get('url')}/uid/${uid}/inbox`; + const payload = { foo: 'bar' }; + const keyData = await activitypub.getPrivateKey('uid', uid); + const { digest } = await activitypub.sign(keyData, endpoint, payload); + const hash = createHash('sha256'); + hash.update(JSON.stringify(payload)); + const checksum = hash.digest('base64'); + + assert(digest); + assert.strictEqual(digest, `SHA-256=${checksum}`); + }); + + it('should create a key for NodeBB itself if a uid of 0 is passed in', async () => { + const endpoint = `${nconf.get('url')}/uid/${uid}/inbox`; + const keyData = await activitypub.getPrivateKey('uid', 0); + await activitypub.sign(keyData, endpoint); + const { publicKey, privateKey } = await db.getObject(`uid:0:keys`); + + assert(publicKey); + assert(privateKey); + }); + + it('should return headers with an appropriate key id uri', async () => { + const endpoint = `${nconf.get('url')}/uid/${uid}/inbox`; + const keyData = await activitypub.getPrivateKey('uid', uid); + const { signature } = await activitypub.sign(keyData, endpoint); + const [keyId] = signature.split(','); + + assert(signature); + assert.strictEqual(keyId, `keyId="${nconf.get('url')}/uid/${uid}#key"`); + }); + + it('should return the instance key id when uid is 0', async () => { + const endpoint = `${nconf.get('url')}/uid/${uid}/inbox`; + const keyData = await activitypub.getPrivateKey('uid', 0); + const { signature } = await activitypub.sign(keyData, endpoint); + const [keyId] = signature.split(','); + + assert(signature); + assert.strictEqual(keyId, `keyId="${nconf.get('url')}/actor#key"`); + }); + }); + + describe('.verify()', () => { + let uid; + let username; + const baseUrl = nconf.get('relative_path'); + const mockReqBase = { + method: 'GET', + // path: ... + baseUrl, + headers: { + // host: ... + // date: ... + // signature: ... + // digest: ... + }, + }; + + before(async () => { + username = utils.generateUUID().slice(0, 10); + uid = await user.create({ username }); + }); + + it('should return true when the proper signature and relevant headers are passed in', async () => { + const endpoint = `${nconf.get('url')}/user/${username}/inbox`; + const path = `/user/${username}/inbox`; + const keyData = await activitypub.getPrivateKey('uid', uid); + const signature = await activitypub.sign(keyData, endpoint); + const { host } = nconf.get('url_parsed'); + const req = { + ...mockReqBase, + ...{ + path, + headers: { ...signature, host }, + }, + }; + + const verified = await activitypub.verify(req); + assert.strictEqual(verified, true); + }); + + it('should return true when a digest is also passed in', async () => { + const endpoint = `${nconf.get('url')}/user/${username}/inbox`; + const path = `/user/${username}/inbox`; + const keyData = await activitypub.getPrivateKey('uid', uid); + const signature = await activitypub.sign(keyData, endpoint, { foo: 'bar' }); + const { host } = nconf.get('url_parsed'); + const req = { + ...mockReqBase, + ...{ + method: 'POST', + path, + headers: { ...signature, host }, + }, + }; + + const verified = await activitypub.verify(req); + assert.strictEqual(verified, true); + }); + }); +}); diff --git a/test/activitypub/webfinger.js b/test/activitypub/webfinger.js new file mode 100644 index 000000000000..9e503ebfc304 --- /dev/null +++ b/test/activitypub/webfinger.js @@ -0,0 +1,63 @@ +'use strict'; + +const assert = require('assert'); +const nconf = require('nconf'); + +const request = require('../../src/request'); +const utils = require('../../src/utils'); +const user = require('../../src/user'); +const slugify = require('../../src/slugify'); +const privileges = require('../../src/privileges'); + +describe('WebFinger endpoint', () => { + let uid; + let slug; + const { host } = nconf.get('url_parsed'); + + beforeEach(async () => { + slug = slugify(utils.generateUUID().slice(0, 8)); + uid = await user.create({ username: slug }); + }); + + it('should return a 404 Not Found if no user exists by that username', async () => { + const { response } = await request.get(`${nconf.get('url')}/.well-known/webfinger?resource=acct%3afoobar%40${host}`); + + assert(response); + assert.strictEqual(response.statusCode, 404); + }); + + it('should return a 400 Bad Request if the request is malformed', async () => { + const { response } = await request.get(`${nconf.get('url')}/.well-known/webfinger?resource=acct%3afoobar`); + + assert(response); + assert.strictEqual(response.statusCode, 400); + }); + + it('should return 403 Forbidden if the calling user is not allowed to view the user list/profiles', async () => { + await privileges.global.rescind(['groups:view:users'], 'guests'); + const { response } = await request.get(`${nconf.get('url')}/.well-known/webfinger?resource=acct%3a${slug}%40${host}`); + + assert(response); + assert.strictEqual(response.statusCode, 400); + await privileges.global.give(['groups:view:users'], 'guests'); + }); + + it('should return a valid WebFinger response otherwise', async () => { + const { response, body } = await request.get(`${nconf.get('url')}/.well-known/webfinger?resource=acct%3a${slug}%40${host}`); + + assert(response); + assert.strictEqual(response.statusCode, 200); + + ['subject', 'aliases', 'links'].forEach((prop) => { + assert(body.hasOwnProperty(prop)); + assert(body[prop]); + }); + + assert.strictEqual(body.subject, `acct:${slug}@${host}`); + + assert(Array.isArray(body.aliases)); + assert([`${nconf.get('url')}/uid/${uid}`, `${nconf.get('url')}/user/${slug}`].every(url => body.aliases.includes(url))); + + assert(Array.isArray(body.links)); + }); +}); diff --git a/test/api.js b/test/api.js index 47961742ff3b..857a5f879961 100644 --- a/test/api.js +++ b/test/api.js @@ -241,6 +241,7 @@ describe('API', async () => { meta.config.allowTopicsThumbnail = 1; meta.config.termsOfUse = 'I, for one, welcome our new test-driven overlords'; meta.config.chatMessageDelay = 0; + meta.config.activitypubEnabled = 1; // Create a category const testCategory = await categories.create({ name: 'test' }); diff --git a/test/controllers.js b/test/controllers.js index 418420303f81..951a2e9616f3 100644 --- a/test/controllers.js +++ b/test/controllers.js @@ -1865,6 +1865,49 @@ describe('Controllers', () => { } }); + describe('.well-known', () => { + describe('webfinger', () => { + let uid; + let username; + + before(async () => { + username = utils.generateUUID().slice(0, 10); + uid = await user.create({ username }); + }); + + it('should error if resource parameter is missing', async () => { + const { response } = await request.get(`${nconf.get('url')}/.well-known/webfinger`); + assert.strictEqual(response.statusCode, 400); + }); + + it('should error if resource parameter is malformed', async () => { + const { response } = await request.get(`${nconf.get('url')}/.well-known/webfinger?resource=foobar`); + assert.strictEqual(response.statusCode, 400); + }); + + it('should deny access if view:users privilege is not enabled for guests', async () => { + await privileges.global.rescind(['groups:view:users'], 'guests'); + + const { response } = await request.get(`${nconf.get('url')}/.well-known/webfinger?resource=acct:${username}@${nconf.get('url_parsed').host}`); + assert.strictEqual(response.statusCode, 400); + + await privileges.global.give(['groups:view:users'], 'guests'); + }); + + it('should respond appropriately if the user requested does not exist locally', async () => { + const { response } = await request.get(`${nconf.get('url')}/.well-known/webfinger?resource=acct:foobar@${nconf.get('url_parsed').host}`); + assert.strictEqual(response.statusCode, 404); + }); + + it('should return a valid webfinger response if the user exists', async () => { + const { response, body } = await request.get(`${nconf.get('url')}/.well-known/webfinger?resource=acct:${username}@${nconf.get('url_parsed').host}`); + assert.strictEqual(response.statusCode, 200); + assert(['subject', 'aliases', 'links'].every(prop => body.hasOwnProperty(prop))); + assert(body.subject, `acct:${username}@${nconf.get('url_parsed').host}`); + }); + }); + }); + after((done) => { const analytics = require('../src/analytics'); analytics.writeData(done); diff --git a/test/template-helpers.js b/test/template-helpers.js index 00ae777f8295..f01bdd642c9a 100644 --- a/test/template-helpers.js +++ b/test/template-helpers.js @@ -151,7 +151,7 @@ describe('helpers', () => { find: 'viewing', read: 'viewing', }; - const html = helpers.spawnPrivilegeStates('guests', privs, types); + const html = helpers.spawnPrivilegeStates(1, 'guests', privs, types); assert.equal(html, `