mirror of
https://github.com/NodeBB/NodeBB.git
synced 2025-12-25 01:40:27 +01:00
Compare commits
66 Commits
v4.7.2
...
crossposti
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce8c0d10e8 | ||
|
|
677e01ab39 | ||
|
|
2661c63e1b | ||
|
|
dddc43e0fe | ||
|
|
a98b1ca39d | ||
|
|
a6178a837f | ||
|
|
5ad54531c6 | ||
|
|
20918b5281 | ||
|
|
8abe0dfa9f | ||
|
|
097d0802b7 | ||
|
|
3adcbe0f7d | ||
|
|
b992511bb9 | ||
|
|
d4f53a6242 | ||
|
|
528cd258c4 | ||
|
|
a2f2c8c761 | ||
|
|
81c232f181 | ||
|
|
f077c4cab8 | ||
|
|
adedb7b626 | ||
|
|
a35c326a6c | ||
|
|
eaa6e71a99 | ||
|
|
011f8b2465 | ||
|
|
2c57cb13e4 | ||
|
|
f6fbb0226b | ||
|
|
823c6cb340 | ||
|
|
e50edd52fc | ||
|
|
10d2e929a1 | ||
|
|
6956270411 | ||
|
|
c529244229 | ||
|
|
f1d50c3510 | ||
|
|
6b1dcb4b90 | ||
|
|
7b734cfdc5 | ||
|
|
6a56105037 | ||
|
|
7d5402fe66 | ||
|
|
d8e55d58de | ||
|
|
4a6dcf1a21 | ||
|
|
2968772287 | ||
|
|
934e6be911 | ||
|
|
f0a7a442db | ||
|
|
7b194c6916 | ||
|
|
4020e1be35 | ||
|
|
76b6b3b259 | ||
|
|
4cdb56904c | ||
|
|
70169758ec | ||
|
|
7f21a17175 | ||
|
|
93057306f4 | ||
|
|
731933a66b | ||
|
|
38321220f2 | ||
|
|
ad5cd27b37 | ||
|
|
ecec1f4594 | ||
|
|
3b7bcba6c0 | ||
|
|
a82e1f441c | ||
|
|
977a67f4cd | ||
|
|
8236b594af | ||
|
|
22d3c52332 | ||
|
|
e39c91497f | ||
|
|
841bd8252c | ||
|
|
5ba6bea049 | ||
|
|
624ef61655 | ||
|
|
287b25695d | ||
|
|
5f55ca85e6 | ||
|
|
1cb8b381d5 | ||
|
|
1bcfe3f09e | ||
|
|
936dede44f | ||
|
|
3ab61615af | ||
|
|
411baa21f4 | ||
|
|
c365c1dc3e |
2
.github/workflows/docker.yml
vendored
2
.github/workflows/docker.yml
vendored
@@ -34,7 +34,7 @@ jobs:
|
||||
platform=${{ matrix.platforms }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY@L}" >> $GITHUB_ENV
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
2
.github/workflows/test.yaml
vendored
2
.github/workflows/test.yaml
vendored
@@ -81,7 +81,7 @@ jobs:
|
||||
- 27017:27017
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- run: cp install/package.json package.json
|
||||
|
||||
|
||||
@@ -33,19 +33,19 @@
|
||||
"@fontsource/inter": "5.2.8",
|
||||
"@fontsource/poppins": "5.2.7",
|
||||
"@fortawesome/fontawesome-free": "6.7.2",
|
||||
"@isaacs/ttlcache": "2.1.2",
|
||||
"@isaacs/ttlcache": "2.1.3",
|
||||
"@nodebb/spider-detector": "2.0.3",
|
||||
"@popperjs/core": "2.11.8",
|
||||
"@textcomplete/contenteditable": "0.1.13",
|
||||
"@textcomplete/core": "0.1.13",
|
||||
"@textcomplete/textarea": "0.1.13",
|
||||
"ace-builds": "1.43.4",
|
||||
"ace-builds": "1.43.5",
|
||||
"archiver": "7.0.1",
|
||||
"async": "3.2.6",
|
||||
"autoprefixer": "10.4.22",
|
||||
"bcryptjs": "3.0.3",
|
||||
"benchpressjs": "2.5.5",
|
||||
"body-parser": "2.2.0",
|
||||
"body-parser": "2.2.1",
|
||||
"bootbox": "6.0.4",
|
||||
"bootstrap": "5.3.8",
|
||||
"bootswatch": "5.3.8",
|
||||
@@ -57,17 +57,17 @@
|
||||
"compare-versions": "6.1.1",
|
||||
"compression": "1.8.1",
|
||||
"connect-flash": "0.1.1",
|
||||
"connect-mongo": "5.1.0",
|
||||
"connect-mongo": "6.0.0",
|
||||
"connect-pg-simple": "10.0.0",
|
||||
"connect-redis": "9.0.0",
|
||||
"cookie-parser": "1.4.7",
|
||||
"cron": "4.3.4",
|
||||
"cron": "4.4.0",
|
||||
"cropperjs": "1.6.2",
|
||||
"csrf-sync": "4.2.1",
|
||||
"daemon": "1.1.0",
|
||||
"diff": "8.0.2",
|
||||
"esbuild": "0.27.0",
|
||||
"express": "4.21.2",
|
||||
"esbuild": "0.27.1",
|
||||
"express": "4.22.1",
|
||||
"express-session": "1.18.2",
|
||||
"express-useragent": "2.0.2",
|
||||
"fetch-cookie": "3.1.0",
|
||||
@@ -77,7 +77,7 @@
|
||||
"helmet": "7.2.0",
|
||||
"html-to-text": "9.0.5",
|
||||
"imagesloaded": "5.0.0",
|
||||
"ipaddr.js": "2.2.0",
|
||||
"ipaddr.js": "2.3.0",
|
||||
"jquery": "3.7.1",
|
||||
"jquery-deserialize": "2.0.0",
|
||||
"jquery-form": "4.3.0",
|
||||
@@ -85,13 +85,13 @@
|
||||
"jquery-ui": "1.14.1",
|
||||
"jsesc": "3.1.0",
|
||||
"json2csv": "5.0.7",
|
||||
"jsonwebtoken": "9.0.2",
|
||||
"jsonwebtoken": "9.0.3",
|
||||
"lodash": "4.17.21",
|
||||
"logrotate-stream": "0.2.9",
|
||||
"lru-cache": "11.2.2",
|
||||
"lru-cache": "11.2.4",
|
||||
"mime": "3.0.0",
|
||||
"mkdirp": "3.0.1",
|
||||
"mongodb": "6.21.0",
|
||||
"mongodb": "7.0.0",
|
||||
"morgan": "1.10.1",
|
||||
"mousetrap": "1.6.5",
|
||||
"multer": "2.0.2",
|
||||
@@ -112,7 +112,7 @@
|
||||
"nodebb-theme-peace": "2.2.49",
|
||||
"nodebb-theme-persona": "14.1.18",
|
||||
"nodebb-widget-essentials": "7.0.41",
|
||||
"nodemailer": "7.0.10",
|
||||
"nodemailer": "7.0.11",
|
||||
"nprogress": "0.2.0",
|
||||
"passport": "0.7.0",
|
||||
"passport-http-bearer": "1.0.1",
|
||||
@@ -124,12 +124,12 @@
|
||||
"pretty": "^2.0.0",
|
||||
"progress-webpack-plugin": "1.0.16",
|
||||
"prompt": "1.3.0",
|
||||
"redis": "5.9.0",
|
||||
"redis": "5.10.0",
|
||||
"rimraf": "6.1.2",
|
||||
"rss": "1.2.2",
|
||||
"rtlcss": "4.3.0",
|
||||
"sanitize-html": "2.17.0",
|
||||
"sass": "1.94.1",
|
||||
"sass": "1.96.0",
|
||||
"satori": "0.18.3",
|
||||
"sbd": "^1.0.19",
|
||||
"semver": "7.7.3",
|
||||
@@ -141,7 +141,7 @@
|
||||
"@socket.io/redis-adapter": "8.3.0",
|
||||
"sortablejs": "1.15.6",
|
||||
"spdx-license-list": "6.10.0",
|
||||
"terser-webpack-plugin": "5.3.14",
|
||||
"terser-webpack-plugin": "5.3.15",
|
||||
"textcomplete": "0.18.2",
|
||||
"textcomplete.contenteditable": "0.1.1",
|
||||
"timeago": "1.6.7",
|
||||
@@ -152,7 +152,7 @@
|
||||
"validator": "13.15.23",
|
||||
"webpack": "5.103.0",
|
||||
"webpack-merge": "6.0.1",
|
||||
"winston": "3.18.3",
|
||||
"winston": "3.19.0",
|
||||
"workerpool": "10.0.1",
|
||||
"xml": "1.0.1",
|
||||
"xregexp": "5.1.2",
|
||||
@@ -161,8 +161,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@apidevtools/swagger-parser": "10.1.0",
|
||||
"@commitlint/cli": "20.1.0",
|
||||
"@commitlint/config-angular": "20.0.0",
|
||||
"@commitlint/cli": "20.2.0",
|
||||
"@commitlint/config-angular": "20.2.0",
|
||||
"coveralls": "3.1.1",
|
||||
"@eslint/js": "9.39.1",
|
||||
"@stylistic/eslint-plugin": "5.6.1",
|
||||
@@ -171,16 +171,16 @@
|
||||
"grunt": "1.6.1",
|
||||
"grunt-contrib-watch": "1.1.0",
|
||||
"husky": "8.0.3",
|
||||
"jsdom": "27.2.0",
|
||||
"lint-staged": "16.2.6",
|
||||
"jsdom": "27.3.0",
|
||||
"lint-staged": "16.2.7",
|
||||
"mocha": "11.7.5",
|
||||
"mocha-lcov-reporter": "1.3.0",
|
||||
"mockdate": "3.0.5",
|
||||
"nyc": "17.1.0",
|
||||
"smtp-server": "3.16.1"
|
||||
"smtp-server": "3.17.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"sass-embedded": "1.93.3"
|
||||
"sass-embedded": "1.96.0"
|
||||
},
|
||||
"resolutions": {
|
||||
"*/jquery": "3.7.1"
|
||||
|
||||
@@ -170,6 +170,8 @@
|
||||
"topic-already-deleted": "This topic has already been deleted",
|
||||
"topic-already-restored": "This topic has already been restored",
|
||||
|
||||
"topic-already-crossposted": "This topic has already been cross-posted there.",
|
||||
|
||||
"cant-purge-main-post": "You can't purge the main post, please delete the topic instead",
|
||||
|
||||
"topic-thumbnails-are-disabled": "Topic thumbnails are disabled.",
|
||||
@@ -262,6 +264,7 @@
|
||||
"no-topics-selected": "No topics selected!",
|
||||
"cant-move-to-same-topic": "Can't move post to same topic!",
|
||||
"cant-move-topic-to-same-category": "Can't move topic to the same category!",
|
||||
"cant-move-topic-to-from-remote-categories": "You cannot move topics in or out of remote categories; consider cross-posting instead.",
|
||||
|
||||
"cannot-block-self": "You cannot block yourself!",
|
||||
"cannot-block-privileged": "You cannot block administrators or global moderators",
|
||||
|
||||
@@ -116,6 +116,7 @@
|
||||
"thread-tools.lock": "Lock Topic",
|
||||
"thread-tools.unlock": "Unlock Topic",
|
||||
"thread-tools.move": "Move Topic",
|
||||
"thread-tools.crosspost": "Crosspost Topic",
|
||||
"thread-tools.move-posts": "Move Posts",
|
||||
"thread-tools.move-all": "Move All",
|
||||
"thread-tools.change-owner": "Change Owner",
|
||||
@@ -149,6 +150,7 @@
|
||||
|
||||
"load-categories": "Loading Categories",
|
||||
"confirm-move": "Move",
|
||||
"confirm-crosspost": "Cross-post",
|
||||
"confirm-fork": "Fork",
|
||||
|
||||
"bookmark": "Bookmark",
|
||||
@@ -161,6 +163,7 @@
|
||||
"loading-more-posts": "Loading More Posts",
|
||||
"move-topic": "Move Topic",
|
||||
"move-topics": "Move Topics",
|
||||
"crosspost-topic": "Cross-post Topic",
|
||||
"move-post": "Move Post",
|
||||
"post-moved": "Post moved!",
|
||||
"fork-topic": "Fork Topic",
|
||||
@@ -181,6 +184,7 @@
|
||||
"topic-id": "Topic ID",
|
||||
"move-posts-instruction": "Click the posts you want to move then enter a topic ID or go to the target topic",
|
||||
"move-topic-instruction": "Select the target category and then click move",
|
||||
"crosspost-topic-instruction": "Select one or more categories to cross-post to. Topic(s) will be accessible from the original category and all cross-posted categories.",
|
||||
"change-owner-instruction": "Click the posts you want to assign to another user",
|
||||
"manage-editors-instruction": "Manage the users who can edit this post below.",
|
||||
|
||||
|
||||
34
public/openapi/components/schemas/CrosspostObject.yaml
Normal file
34
public/openapi/components/schemas/CrosspostObject.yaml
Normal file
@@ -0,0 +1,34 @@
|
||||
CrosspostObject:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: The cross-post ID
|
||||
cid:
|
||||
type: object
|
||||
description: The category id that the topic was cross-posted to
|
||||
additionalProperties:
|
||||
oneOf:
|
||||
- type: string
|
||||
- type: number
|
||||
tid:
|
||||
type: object
|
||||
description: The topic id that was cross-posted
|
||||
additionalProperties:
|
||||
oneOf:
|
||||
- type: string
|
||||
- type: number
|
||||
timestamp:
|
||||
type: number
|
||||
uid:
|
||||
type: object
|
||||
description: The user id that initiated the cross-post
|
||||
additionalProperties:
|
||||
oneOf:
|
||||
- type: string
|
||||
- type: number
|
||||
CrosspostsArray:
|
||||
type: array
|
||||
description: A list of crosspost objects
|
||||
items:
|
||||
$ref: '#/CrosspostObject'
|
||||
@@ -168,6 +168,8 @@ paths:
|
||||
$ref: 'write/topics/tid/bump.yaml'
|
||||
/topics/{tid}/move:
|
||||
$ref: 'write/topics/tid/move.yaml'
|
||||
/topics/{tid}/crossposts:
|
||||
$ref: 'write/topics/tid/crossposts.yaml'
|
||||
/tags/{tag}/follow:
|
||||
$ref: 'write/tags/tag/follow.yaml'
|
||||
/posts/{pid}:
|
||||
|
||||
@@ -86,7 +86,6 @@ put:
|
||||
privileges:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: boolean
|
||||
description: A set of privileges with either true or false
|
||||
types:
|
||||
type: object
|
||||
@@ -103,7 +102,6 @@ put:
|
||||
privileges:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: boolean
|
||||
description: A set of privileges with either true or false
|
||||
types:
|
||||
type: object
|
||||
|
||||
@@ -47,7 +47,6 @@ get:
|
||||
privileges:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: boolean
|
||||
description: A set of privileges with either true or false
|
||||
isPrivate:
|
||||
type: boolean
|
||||
@@ -65,7 +64,6 @@ get:
|
||||
privileges:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: boolean
|
||||
description: A set of privileges with either true or false
|
||||
types:
|
||||
type: object
|
||||
|
||||
@@ -93,7 +93,6 @@ put:
|
||||
privileges:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: boolean
|
||||
description: A set of privileges with either true or false
|
||||
groups:
|
||||
type: array
|
||||
@@ -107,7 +106,6 @@ put:
|
||||
privileges:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: boolean
|
||||
description: A set of privileges with either true or false
|
||||
types:
|
||||
type: object
|
||||
@@ -230,7 +228,6 @@ delete:
|
||||
privileges:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: boolean
|
||||
description: A set of privileges with either true or false
|
||||
groups:
|
||||
type: array
|
||||
@@ -244,7 +241,6 @@ delete:
|
||||
privileges:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: boolean
|
||||
description: A set of privileges with either true or false
|
||||
types:
|
||||
type: object
|
||||
|
||||
@@ -71,5 +71,4 @@ get:
|
||||
privileges:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: boolean
|
||||
description: A set of privileges with either true or false
|
||||
76
public/openapi/write/topics/tid/crossposts.yaml
Normal file
76
public/openapi/write/topics/tid/crossposts.yaml
Normal file
@@ -0,0 +1,76 @@
|
||||
post:
|
||||
tags:
|
||||
- topics
|
||||
summary: crosspost a topic
|
||||
description: This operation crossposts a topic to another category.
|
||||
parameters:
|
||||
- in: path
|
||||
name: tid
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
description: a valid topic id
|
||||
example: 1
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
cid:
|
||||
type: number
|
||||
example: 1
|
||||
responses:
|
||||
'200':
|
||||
description: Topic successfully crossposted
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
$ref: ../../../components/schemas/Status.yaml#/Status
|
||||
response:
|
||||
type: object
|
||||
properties:
|
||||
crossposts:
|
||||
$ref: ../../../components/schemas/CrosspostObject.yaml#/CrosspostsArray
|
||||
delete:
|
||||
tags:
|
||||
- topics
|
||||
summary: uncrossposts a topic
|
||||
description: This operation uncrossposts a topic from a category.
|
||||
parameters:
|
||||
- in: path
|
||||
name: tid
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
description: a valid topic id
|
||||
example: 1
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
cid:
|
||||
type: number
|
||||
example: 1
|
||||
responses:
|
||||
'200':
|
||||
description: Topic successfully uncrossposted
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
$ref: ../../../components/schemas/Status.yaml#/Status
|
||||
response:
|
||||
type: object
|
||||
properties:
|
||||
crossposts:
|
||||
$ref: ../../../components/schemas/CrosspostObject.yaml#/CrosspostsArray
|
||||
@@ -34,6 +34,7 @@ define('forum/topic/move', [
|
||||
categorySelector.init(dropdownEl, {
|
||||
onSelect: onCategorySelected,
|
||||
privilege: 'moderate',
|
||||
localOnly: true,
|
||||
});
|
||||
|
||||
modal.find('#move_thread_commit').on('click', onCommitClicked);
|
||||
|
||||
@@ -116,6 +116,12 @@ define('forum/topic/threadTools', [
|
||||
return false;
|
||||
});
|
||||
|
||||
topicContainer.on('click', '[component="topic/crosspost"]', () => {
|
||||
require(['forum/topic/crosspost'], (crosspost) => {
|
||||
crosspost.init(tid, ajaxify.data.cid);
|
||||
});
|
||||
});
|
||||
|
||||
topicContainer.on('click', '[component="topic/delete/posts"]', function () {
|
||||
require(['forum/topic/delete-posts'], function (deletePosts) {
|
||||
deletePosts.init();
|
||||
|
||||
@@ -76,6 +76,8 @@ define('categorySearch', ['alerts', 'bootstrap', 'api'], function (alerts, boots
|
||||
privilege: options.privilege,
|
||||
states: options.states,
|
||||
showLinks: options.showLinks,
|
||||
localOnly: options.localOnly,
|
||||
hideUncategorized: options.hideUncategorized,
|
||||
}, function (err, { categories }) {
|
||||
if (err) {
|
||||
return alerts.error(err);
|
||||
@@ -93,6 +95,7 @@ define('categorySearch', ['alerts', 'bootstrap', 'api'], function (alerts, boots
|
||||
categoryItems: categories.slice(0, 200),
|
||||
selectedCategory: ajaxify.data.selectedCategory,
|
||||
allCategoriesUrl: ajaxify.data.allCategoriesUrl,
|
||||
hideAll: options.hideAll,
|
||||
}, function (html) {
|
||||
el.find('[component="category/list"]')
|
||||
.html(html.find('[component="category/list"]').html());
|
||||
|
||||
@@ -147,7 +147,7 @@ Actors.assert = async (ids, options = {}) => {
|
||||
categories.add(actor.id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (
|
||||
!typeOk ||
|
||||
!activitypub._constants.requiredActorProps.every(prop => actor.hasOwnProperty(prop))
|
||||
@@ -351,7 +351,7 @@ Actors.assertGroup = async (ids, options = {}) => {
|
||||
}));
|
||||
groups = groups.filter(Boolean); // remove unresolvable actors
|
||||
|
||||
// Build userData object for storage
|
||||
// Build categoryData object for storage
|
||||
const categoryObjs = (await activitypub.mocks.category(groups)).filter(Boolean);
|
||||
const now = Date.now();
|
||||
|
||||
@@ -400,12 +400,20 @@ Actors.assertGroup = async (ids, options = {}) => {
|
||||
db.deleteObjectFields('handle:cid', queries.handleRemove),
|
||||
]);
|
||||
|
||||
// Privilege mask
|
||||
const [masksAdd, masksRemove] = categoryObjs.reduce(([add, remove], category) => {
|
||||
(category?._activitypub?.postingRestrictedToMods ? add : remove).push(`cid:${category.cid}:privilegeMask`);
|
||||
return [add, remove];
|
||||
}, [[], []]);
|
||||
|
||||
await Promise.all([
|
||||
db.setObjectBulk(bulkSet),
|
||||
db.sortedSetAdd('usersRemote:lastCrawled', groups.map(() => now), groups.map(p => p.id)),
|
||||
db.sortedSetAddBulk(queries.searchAdd),
|
||||
db.setObject('handle:cid', queries.handleAdd),
|
||||
_migratePersonToGroup(categoryObjs),
|
||||
db.setsAdd(masksAdd, 'topics:create'),
|
||||
db.setsRemove(masksRemove, 'topics:create'),
|
||||
]);
|
||||
|
||||
return categoryObjs;
|
||||
|
||||
@@ -352,6 +352,26 @@ inbox.like = async (req) => {
|
||||
socketHelpers.upvote(result, 'notifications:upvoted-your-post-in');
|
||||
};
|
||||
|
||||
inbox.dislike = 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('Dislike', object, actor);
|
||||
}
|
||||
|
||||
const allowed = await privileges.posts.can('posts:downvote', id, activitypub._constants.uid);
|
||||
if (!allowed) {
|
||||
activitypub.helpers.log(`[activitypub/inbox.like] ${id} not allowed to be downvoted.`);
|
||||
return reject('Dislike', object, actor);
|
||||
}
|
||||
|
||||
activitypub.helpers.log(`[activitypub/inbox/dislike] id ${id} via ${actor}`);
|
||||
|
||||
await posts.downvote(id, actor);
|
||||
await activitypub.feps.announce(object.id, req.body);
|
||||
};
|
||||
|
||||
inbox.announce = async (req) => {
|
||||
let { actor, object, published, to, cc } = req.body;
|
||||
activitypub.helpers.log(`[activitypub/inbox/announce] Parsing Announce(${object.type}) from ${actor}`);
|
||||
|
||||
@@ -13,6 +13,7 @@ const categories = require('../categories');
|
||||
const posts = require('../posts');
|
||||
const topics = require('../topics');
|
||||
const messaging = require('../messaging');
|
||||
const privileges = require('../privileges');
|
||||
const plugins = require('../plugins');
|
||||
const slugify = require('../slugify');
|
||||
const translator = require('../translator');
|
||||
@@ -280,6 +281,7 @@ Mocks.category = async (actors) => {
|
||||
let {
|
||||
url, preferredUsername, icon, /* image, */
|
||||
name, summary, followers, inbox, endpoints, tag,
|
||||
postingRestrictedToMods,
|
||||
} = actor;
|
||||
preferredUsername = slugify(preferredUsername || name);
|
||||
/*
|
||||
@@ -337,6 +339,10 @@ Mocks.category = async (actors) => {
|
||||
inbox,
|
||||
sharedInbox: endpoints ? endpoints.sharedInbox : null,
|
||||
followersUrl: followers,
|
||||
|
||||
_activitypub: {
|
||||
postingRestrictedToMods,
|
||||
},
|
||||
};
|
||||
|
||||
return payload;
|
||||
@@ -524,12 +530,19 @@ Mocks.actors.user = async (uid) => {
|
||||
};
|
||||
|
||||
Mocks.actors.category = async (cid) => {
|
||||
const {
|
||||
name, handle: preferredUsername, slug,
|
||||
descriptionParsed: summary, backgroundImage,
|
||||
} = await categories.getCategoryFields(cid,
|
||||
['name', 'handle', 'slug', 'description', 'descriptionParsed', 'backgroundImage']);
|
||||
const publicKey = await activitypub.getPublicKey('cid', cid);
|
||||
const [
|
||||
{
|
||||
name, handle: preferredUsername, slug,
|
||||
descriptionParsed: summary, backgroundImage,
|
||||
},
|
||||
publicKey,
|
||||
canPost,
|
||||
] = await Promise.all([
|
||||
categories.getCategoryFields(cid,
|
||||
['name', 'handle', 'slug', 'description', 'descriptionParsed', 'backgroundImage']),
|
||||
activitypub.getPublicKey('cid', cid),
|
||||
privileges.categories.can('topics:create', cid, -2),
|
||||
]);
|
||||
|
||||
let icon;
|
||||
if (backgroundImage) {
|
||||
@@ -553,6 +566,7 @@ Mocks.actors.category = async (cid) => {
|
||||
'@context': [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
'https://w3id.org/security/v1',
|
||||
'https://join-lemmy.org/context.json',
|
||||
],
|
||||
id: `${nconf.get('url')}/category/${cid}`,
|
||||
url: `${nconf.get('url')}/category/${slug}`,
|
||||
@@ -567,6 +581,7 @@ Mocks.actors.category = async (cid) => {
|
||||
summary,
|
||||
// image, // todo once categories have cover photos
|
||||
icon,
|
||||
postingRestrictedToMods: !canPost,
|
||||
|
||||
publicKey: {
|
||||
id: `${nconf.get('url')}/category/${cid}#key`,
|
||||
|
||||
@@ -265,8 +265,8 @@ Notes.assert = async (uid, input, options = { skipChecks: false }) => {
|
||||
|
||||
await Notes.syncUserInboxes(tid, uid);
|
||||
|
||||
if (!hasTid && options.cid) {
|
||||
// New topic, have category announce it
|
||||
if (!hasTid && uid && options.cid) {
|
||||
// New topic via search/post-redirect, have category announce it
|
||||
activitypub.out.announce.topic(tid);
|
||||
}
|
||||
|
||||
|
||||
@@ -277,6 +277,32 @@ Out.like.note = enabledCheck(async (uid, pid) => {
|
||||
]);
|
||||
});
|
||||
|
||||
Out.dislike = {};
|
||||
|
||||
Out.dislike.note = enabledCheck(async (uid, pid) => {
|
||||
const payload = {
|
||||
id: `${nconf.get('url')}/uid/${uid}#activity/dislike/${encodeURIComponent(pid)}`,
|
||||
type: 'Dislike',
|
||||
actor: `${nconf.get('url')}/uid/${uid}`,
|
||||
object: utils.isNumber(pid) ? `${nconf.get('url')}/post/${pid}` : pid,
|
||||
};
|
||||
|
||||
if (!activitypub.helpers.isUri(pid)) { // only 1b12 announce for local likes
|
||||
await activitypub.feps.announce(pid, payload);
|
||||
return;
|
||||
}
|
||||
|
||||
const recipient = await posts.getPostField(pid, 'uid');
|
||||
if (!activitypub.helpers.isUri(recipient)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
activitypub.send('uid', uid, [recipient], payload),
|
||||
activitypub.feps.announce(pid, payload),
|
||||
]);
|
||||
});
|
||||
|
||||
Out.announce = {};
|
||||
|
||||
Out.announce.topic = enabledCheck(async (tid) => {
|
||||
|
||||
@@ -7,6 +7,7 @@ const posts = require('../posts');
|
||||
const privileges = require('../privileges');
|
||||
const plugins = require('../plugins');
|
||||
const activitypub = require('../activitypub');
|
||||
const utils = require('../utils');
|
||||
const socketHelpers = require('../socket.io/helpers');
|
||||
const websockets = require('../socket.io');
|
||||
const events = require('../events');
|
||||
@@ -66,11 +67,22 @@ exports.doTopicAction = async function (action, event, caller, { tids }) {
|
||||
const uids = await user.getUidsFromSet('users:online', 0, -1);
|
||||
|
||||
await Promise.all(tids.map(async (tid) => {
|
||||
const title = await topics.getTopicField(tid, 'title');
|
||||
const { title, cid, mainPid } = await topics.getTopicFields(tid, ['title', 'cid', 'mainPid']);
|
||||
const data = await topics.tools[action](tid, caller.uid);
|
||||
const notifyUids = await privileges.categories.filterUids('topics:read', data.cid, uids);
|
||||
socketHelpers.emitToUids(event, data, notifyUids);
|
||||
await logTopicAction(action, caller, tid, title);
|
||||
|
||||
switch(action) {
|
||||
case 'delete': // falls through
|
||||
case 'purge': {
|
||||
if (utils.isNumber(cid) && parseInt(cid, 10) > 0) {
|
||||
activitypub.out.remove.context(caller.uid, tid); // 7888-style
|
||||
activitypub.out.delete.note(caller.uid, mainPid); // 1b12-style
|
||||
activitypub.out.undo.announce('cid', cid, tid); // microblogs
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -135,14 +147,34 @@ async function executeCommand(caller, command, eventName, notification, data) {
|
||||
websockets.in(`uid_${caller.uid}`).emit(`posts.${command}`, result);
|
||||
websockets.in(data.room_id).emit(`event:${eventName}`, result);
|
||||
}
|
||||
if (result && command === 'upvote') {
|
||||
socketHelpers.upvote(result, notification);
|
||||
await activitypub.out.like.note(caller.uid, 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);
|
||||
await activitypub.out.undo.like(caller.uid, data.pid);
|
||||
|
||||
if (result) {
|
||||
switch (command) {
|
||||
case 'upvote': {
|
||||
socketHelpers.upvote(result, notification);
|
||||
await activitypub.out.like.note(caller.uid, data.pid);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'downvote': {
|
||||
await activitypub.out.dislike.note(caller.uid, data.pid);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'unvote': {
|
||||
socketHelpers.rescindUpvoteNotification(data.pid, caller.uid);
|
||||
await activitypub.out.undo.like(caller.uid, data.pid);
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
if (notification) {
|
||||
socketHelpers.sendNotificationToPostOwner(data.pid, caller.uid, command, notification);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ const messaging = require('../messaging');
|
||||
const privileges = require('../privileges');
|
||||
const meta = require('../meta');
|
||||
const plugins = require('../plugins');
|
||||
const utils = require('../utils');
|
||||
|
||||
const controllersHelpers = require('../controllers/helpers');
|
||||
|
||||
@@ -29,9 +30,12 @@ searchApi.categories = async (caller, data) => {
|
||||
({ cids, matchedCids } = await findMatchedCids(caller.uid, data));
|
||||
} else {
|
||||
cids = await loadCids(caller.uid, data.parentCid);
|
||||
if (meta.config.activitypubEnabled) {
|
||||
if (!data.hideUncategorized && meta.config.activitypubEnabled) {
|
||||
cids.unshift(-1);
|
||||
}
|
||||
if (data.localOnly) {
|
||||
cids = cids.filter(cid => utils.isNumber(cid));
|
||||
}
|
||||
}
|
||||
|
||||
const visibleCategories = await controllersHelpers.getVisibleCategories({
|
||||
@@ -66,6 +70,7 @@ async function findMatchedCids(uid, data) {
|
||||
query: data.search,
|
||||
qs: data.query,
|
||||
paginate: false,
|
||||
localOnly: data.localOnly,
|
||||
});
|
||||
|
||||
let matchedCids = result.categories.map(c => c.cid);
|
||||
|
||||
@@ -325,13 +325,12 @@ topicsAPI.move = async (caller, { tid, cid }) => {
|
||||
|
||||
if (utils.isNumber(cid) && parseInt(cid, 10) === -1) {
|
||||
activitypub.out.remove.context(caller.uid, tid); // 7888-style
|
||||
activitypub.out.delete.note(caller.uid, topicData.mainPid); // threadiverse
|
||||
// tbd: activitypubApi.undo.announce? // microblogs
|
||||
activitypub.out.delete.note(caller.uid, topicData.mainPid); // 1b12-style
|
||||
} else {
|
||||
activitypub.out.move.context(caller.uid, tid);
|
||||
activitypub.out.announce.topic(tid);
|
||||
}
|
||||
activitypub.out.undo.announce('cid', topicData.cid, tid);
|
||||
activitypub.out.undo.announce('cid', topicData.cid, tid); // microblogs
|
||||
}
|
||||
|
||||
await events.log({
|
||||
|
||||
@@ -6,12 +6,14 @@ const privileges = require('../privileges');
|
||||
const activitypub = require('../activitypub');
|
||||
const plugins = require('../plugins');
|
||||
const db = require('../database');
|
||||
const utils = require('../utils');
|
||||
|
||||
module.exports = function (Categories) {
|
||||
Categories.search = async function (data) {
|
||||
const query = data.query || '';
|
||||
const page = data.page || 1;
|
||||
const uid = data.uid || 0;
|
||||
const localOnly = data.localOnly || false;
|
||||
const paginate = data.hasOwnProperty('paginate') ? data.paginate : true;
|
||||
|
||||
const startTime = process.hrtime();
|
||||
@@ -21,6 +23,9 @@ module.exports = function (Categories) {
|
||||
}
|
||||
|
||||
let cids = await findCids(query, data.hardCap);
|
||||
if (localOnly) {
|
||||
cids = cids.filter(cid => utils.isNumber(cid));
|
||||
}
|
||||
|
||||
const result = await plugins.hooks.fire('filter:categories.search', {
|
||||
data: data,
|
||||
|
||||
@@ -123,8 +123,9 @@ topicsController.get = async function getTopic(req, res, next) {
|
||||
p => parseInt(p.index, 10) === parseInt(Math.max(0, postIndex - 1), 10)
|
||||
);
|
||||
|
||||
const [author] = await Promise.all([
|
||||
const [author, crossposts] = await Promise.all([
|
||||
user.getUserFields(topicData.uid, ['username', 'userslug']),
|
||||
topics.crossposts.get(topicData.tid),
|
||||
buildBreadcrumbs(topicData),
|
||||
addOldCategory(topicData, userPrivileges),
|
||||
addTags(topicData, req, res, currentPage, postAtIndex),
|
||||
@@ -134,6 +135,7 @@ topicsController.get = async function getTopic(req, res, next) {
|
||||
]);
|
||||
|
||||
topicData.author = author;
|
||||
topicData.crossposts = crossposts;
|
||||
topicData.pagination = pagination.create(currentPage, pageCount, req.query);
|
||||
topicData.pagination.rel.forEach((rel) => {
|
||||
rel.href = `${url}/topic/${topicData.slug}${rel.href}`;
|
||||
|
||||
@@ -213,3 +213,17 @@ Topics.move = async (req, res) => {
|
||||
|
||||
helpers.formatApiResponse(200, res);
|
||||
};
|
||||
|
||||
Topics.crosspost = async (req, res) => {
|
||||
const { cid } = req.body;
|
||||
const crossposts = await topics.crossposts.add(req.params.tid, cid, req.uid);
|
||||
|
||||
helpers.formatApiResponse(200, res, { crossposts });
|
||||
};
|
||||
|
||||
Topics.uncrosspost = async (req, res) => {
|
||||
const { cid } = req.body;
|
||||
const crossposts = await topics.crossposts.remove(req.params.tid, cid, req.uid);
|
||||
|
||||
helpers.formatApiResponse(200, res, { crossposts });
|
||||
};
|
||||
|
||||
@@ -65,7 +65,7 @@ mongoModule.init = async function (opts) {
|
||||
};
|
||||
|
||||
mongoModule.createSessionStore = async function (options) {
|
||||
const MongoStore = require('connect-mongo');
|
||||
const { MongoStore } = require('connect-mongo');
|
||||
const meta = require('../meta');
|
||||
|
||||
const store = MongoStore.create({
|
||||
|
||||
@@ -68,6 +68,31 @@ module.exports = function (module) {
|
||||
}
|
||||
};
|
||||
|
||||
module.setAddBulk = async function (data) {
|
||||
if (!data.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bulk = module.client.collection('objects').initializeUnorderedBulkOp();
|
||||
|
||||
data.forEach(([key, member]) => {
|
||||
bulk.find({ _key: key }).upsert().updateOne({
|
||||
$addToSet: {
|
||||
members: helpers.valueToString(member),
|
||||
},
|
||||
});
|
||||
});
|
||||
try {
|
||||
await bulk.execute();
|
||||
} catch (err) {
|
||||
if (err && err.message.includes('E11000 duplicate key error')) {
|
||||
console.log(new Error('e11000').stack, data);
|
||||
return await module.setAddBulk(data);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
module.setRemove = async function (key, value) {
|
||||
if (!Array.isArray(value)) {
|
||||
value = [value];
|
||||
@@ -75,11 +100,17 @@ module.exports = function (module) {
|
||||
|
||||
value = value.map(v => helpers.valueToString(v));
|
||||
|
||||
await module.client.collection('objects').updateMany({
|
||||
const coll = module.client.collection('objects');
|
||||
await coll.updateMany({
|
||||
_key: Array.isArray(key) ? { $in: key } : key,
|
||||
}, {
|
||||
$pullAll: { members: value },
|
||||
});
|
||||
|
||||
await coll.deleteMany({
|
||||
_key: Array.isArray(key) ? { $in: key } : key,
|
||||
members: { $size: 0 },
|
||||
});
|
||||
};
|
||||
|
||||
module.setsRemove = async function (keys, value) {
|
||||
@@ -88,11 +119,17 @@ module.exports = function (module) {
|
||||
}
|
||||
value = helpers.valueToString(value);
|
||||
|
||||
await module.client.collection('objects').updateMany({
|
||||
const coll = module.client.collection('objects');
|
||||
await coll.updateMany({
|
||||
_key: { $in: keys },
|
||||
}, {
|
||||
$pull: { members: value },
|
||||
});
|
||||
|
||||
await coll.deleteMany({
|
||||
_key: { $in: keys },
|
||||
members: { $size: 0 },
|
||||
});
|
||||
};
|
||||
|
||||
module.isSetMember = async function (key, value) {
|
||||
|
||||
@@ -28,6 +28,11 @@ module.exports = function (module) {
|
||||
return members.map(member => member.length > 0);
|
||||
}
|
||||
|
||||
async function checkIfSetsExist(keys) {
|
||||
const members = await Promise.all(keys.map(module.getSetMembers));
|
||||
return members.map(member => member.length > 0);
|
||||
}
|
||||
|
||||
async function checkIfKeysExist(keys) {
|
||||
const res = await module.pool.query({
|
||||
name: 'existsArray',
|
||||
@@ -44,13 +49,16 @@ module.exports = function (module) {
|
||||
if (isArray) {
|
||||
const types = await Promise.all(key.map(module.type));
|
||||
const zsetKeys = key.filter((_key, i) => types[i] === 'zset');
|
||||
const otherKeys = key.filter((_key, i) => types[i] !== 'zset');
|
||||
const [zsetExits, otherExists] = await Promise.all([
|
||||
const setKeys = key.filter((_key, i) => types[i] === 'set');
|
||||
const otherKeys = key.filter((_key, i) => types[i] !== 'zset' && types[i] !== 'set');
|
||||
const [zsetExits, setExists, otherExists] = await Promise.all([
|
||||
checkIfzSetsExist(zsetKeys),
|
||||
checkIfSetsExist(setKeys),
|
||||
checkIfKeysExist(otherKeys),
|
||||
]);
|
||||
const existsMap = Object.create(null);
|
||||
zsetKeys.forEach((k, i) => { existsMap[k] = zsetExits[i]; });
|
||||
setKeys.forEach((k, i) => { existsMap[k] = setExists[i]; });
|
||||
otherKeys.forEach((k, i) => { existsMap[k] = otherExists[i]; });
|
||||
return key.map(k => existsMap[k]);
|
||||
}
|
||||
@@ -58,6 +66,9 @@ module.exports = function (module) {
|
||||
if (type === 'zset') {
|
||||
const members = await module.getSortedSetRange(key, 0, 0);
|
||||
return members.length > 0;
|
||||
} else if (type === 'set') {
|
||||
const members = await module.getSetMembers(key);
|
||||
return members.length > 0;
|
||||
}
|
||||
const res = await module.pool.query({
|
||||
name: 'exists',
|
||||
|
||||
@@ -54,6 +54,32 @@ DO NOTHING`,
|
||||
});
|
||||
};
|
||||
|
||||
module.setAddBulk = async function (data) {
|
||||
if (!data.length) {
|
||||
return;
|
||||
}
|
||||
const keys = [];
|
||||
const members = [];
|
||||
|
||||
for (const [key, member] of data) {
|
||||
keys.push(key);
|
||||
members.push(member);
|
||||
}
|
||||
await module.transaction(async (client) => {
|
||||
await helpers.ensureLegacyObjectsType(client, keys, 'set');
|
||||
await client.query({
|
||||
name: 'setAddBulk',
|
||||
text: `
|
||||
INSERT INTO "legacy_set" ("_key", "member")
|
||||
SELECT k, m
|
||||
FROM UNNEST($1::TEXT[], $2::TEXT[]) AS t(k, m)
|
||||
ON CONFLICT ("_key", "member")
|
||||
DO NOTHING;`,
|
||||
values: [keys, members],
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
module.setRemove = async function (key, value) {
|
||||
if (!Array.isArray(key)) {
|
||||
key = [key];
|
||||
|
||||
@@ -14,11 +14,29 @@ module.exports = function (module) {
|
||||
};
|
||||
|
||||
module.setsAdd = async function (keys, value) {
|
||||
if (!Array.isArray(keys) || !keys.length) {
|
||||
if (!Array.isArray(keys) || !keys.length || !value) {
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(value)) {
|
||||
value = [value];
|
||||
}
|
||||
if (!value.length) {
|
||||
return;
|
||||
}
|
||||
const batch = module.client.batch();
|
||||
keys.forEach(k => batch.sAdd(String(k), String(value)));
|
||||
keys.forEach((k) => {
|
||||
value.forEach(v => batch.sAdd(String(k), String(v)));
|
||||
});
|
||||
await helpers.execBatch(batch);
|
||||
};
|
||||
|
||||
module.setAddBulk = async function (data) {
|
||||
if (!data.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const batch = module.client.batch();
|
||||
data.forEach(([key, member]) => batch.sAdd(String(key), String(member)));
|
||||
await helpers.execBatch(batch);
|
||||
};
|
||||
|
||||
|
||||
@@ -177,16 +177,18 @@ module.exports = function (Posts) {
|
||||
}
|
||||
const now = Date.now();
|
||||
|
||||
if (type === 'upvote' && !unvote) {
|
||||
await db.sortedSetAdd(`uid:${uid}:upvote`, now, pid);
|
||||
} else {
|
||||
await db.sortedSetRemove(`uid:${uid}:upvote`, pid);
|
||||
}
|
||||
if (utils.isNumber(uid)) {
|
||||
if (type === 'upvote' && !unvote) {
|
||||
await db.sortedSetAdd(`uid:${uid}:upvote`, now, pid);
|
||||
} else {
|
||||
await db.sortedSetRemove(`uid:${uid}:upvote`, pid);
|
||||
}
|
||||
|
||||
if (type === 'upvote' || unvote) {
|
||||
await db.sortedSetRemove(`uid:${uid}:downvote`, pid);
|
||||
} else {
|
||||
await db.sortedSetAdd(`uid:${uid}:downvote`, now, pid);
|
||||
if (type === 'upvote' || unvote) {
|
||||
await db.sortedSetRemove(`uid:${uid}:downvote`, pid);
|
||||
} else {
|
||||
await db.sortedSetAdd(`uid:${uid}:downvote`, now, pid);
|
||||
}
|
||||
}
|
||||
|
||||
const postData = await Posts.getPostFields(pid, ['pid', 'uid', 'tid']);
|
||||
|
||||
@@ -96,14 +96,16 @@ privsCategories.get = async function (cid, uid) {
|
||||
'topics:tag', 'read', 'posts:view_deleted',
|
||||
];
|
||||
|
||||
const [userPrivileges, isAdministrator, isModerator] = await Promise.all([
|
||||
let [userPrivileges, isAdministrator, isModerator] = await Promise.all([
|
||||
helpers.isAllowedTo(privs, uid, cid),
|
||||
user.isAdministrator(uid),
|
||||
user.isModerator(uid, cid),
|
||||
]);
|
||||
|
||||
const combined = userPrivileges.map(allowed => allowed || isAdministrator);
|
||||
const privData = _.zipObject(privs, combined);
|
||||
if (utils.isNumber(cid)) {
|
||||
userPrivileges = userPrivileges.map(allowed => allowed || isAdministrator);
|
||||
}
|
||||
const privData = _.zipObject(privs, userPrivileges);
|
||||
const isAdminOrMod = isAdministrator || isModerator;
|
||||
|
||||
return await plugins.hooks.fire('filter:privileges.categories.get', {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
const _ = require('lodash');
|
||||
const validator = require('validator');
|
||||
|
||||
const db = require('../database');
|
||||
const groups = require('../groups');
|
||||
const user = require('../user');
|
||||
const categories = require('../categories');
|
||||
@@ -25,16 +26,25 @@ helpers.isUsersAllowedTo = async function (privilege, uids, cid) {
|
||||
cid = -1;
|
||||
}
|
||||
|
||||
const [hasUserPrivilege, hasGroupPrivilege] = await Promise.all([
|
||||
groups.isMembers(uids, `cid:${cid}:privileges:${privilege}`),
|
||||
groups.isMembersOfGroupList(uids, `cid:${cid}:privileges:groups:${privilege}`),
|
||||
]);
|
||||
const allowed = uids.map((uid, index) => hasUserPrivilege[index] || hasGroupPrivilege[index]);
|
||||
const result = await plugins.hooks.fire('filter:privileges:isUsersAllowedTo', { allowed: allowed, privilege: privilege, uids: uids, cid: cid });
|
||||
let allowed;
|
||||
const masked = await db.isSetMember(`cid:${cid}:privilegeMask`, privilege);
|
||||
if (!masked) {
|
||||
const [hasUserPrivilege, hasGroupPrivilege] = await Promise.all([
|
||||
groups.isMembers(uids, `cid:${cid}:privileges:${privilege}`),
|
||||
groups.isMembersOfGroupList(uids, `cid:${cid}:privileges:groups:${privilege}`),
|
||||
]);
|
||||
allowed = uids.map((uid, index) => hasUserPrivilege[index] || hasGroupPrivilege[index]);
|
||||
} else {
|
||||
allowed = uids.map(() => false);
|
||||
}
|
||||
|
||||
const result = await plugins.hooks.fire('filter:privileges:isUsersAllowedTo', { allowed, privilege, uids, cid });
|
||||
return result.allowed;
|
||||
};
|
||||
|
||||
helpers.isAllowedTo = async function (privilege, uidOrGroupName, cid) {
|
||||
const _cid = cid; // original passed-in cid needed for privilege mask checks
|
||||
|
||||
// Remote categories (non-numeric) inherit world privileges
|
||||
if (Array.isArray(cid)) {
|
||||
cid = cid.map(cid => (utils.isNumber(cid) ? cid : -1));
|
||||
@@ -44,10 +54,15 @@ helpers.isAllowedTo = async function (privilege, uidOrGroupName, cid) {
|
||||
|
||||
let allowed;
|
||||
if (Array.isArray(privilege) && !Array.isArray(cid)) {
|
||||
const mask = await db.isSetMembers(`cid:${_cid}:privilegeMask`, privilege);
|
||||
allowed = await isAllowedToPrivileges(privilege, uidOrGroupName, cid);
|
||||
allowed = allowed.map((allowed, idx) => mask[idx] ? false : allowed);
|
||||
} else if (Array.isArray(cid) && !Array.isArray(privilege)) {
|
||||
const mask = await db.isMemberOfSets(_cid.map(cid => `cid:${cid}:privilegeMask`), privilege);
|
||||
allowed = await isAllowedToCids(privilege, uidOrGroupName, cid);
|
||||
allowed = allowed.map((allowed, idx) => mask[idx] ? false : allowed);
|
||||
}
|
||||
|
||||
if (allowed) {
|
||||
({ allowed } = await plugins.hooks.fire('filter:privileges:isAllowedTo', { allowed: allowed, privilege: privilege, uid: uidOrGroupName, cid: cid }));
|
||||
return allowed;
|
||||
|
||||
@@ -54,5 +54,8 @@ module.exports = function () {
|
||||
|
||||
setupApiRoute(router, 'put', '/:tid/move', [...middlewares, middleware.assert.topic], controllers.write.topics.move);
|
||||
|
||||
setupApiRoute(router, 'post', '/:tid/crossposts', [...middlewares, middleware.assert.topic], controllers.write.topics.crosspost);
|
||||
setupApiRoute(router, 'delete', '/:tid/crossposts', [...middlewares, middleware.assert.topic], controllers.write.topics.uncrosspost);
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
127
src/topics/crossposts.js
Normal file
127
src/topics/crossposts.js
Normal file
@@ -0,0 +1,127 @@
|
||||
'use strict';
|
||||
|
||||
const db = require('../database');
|
||||
const topics = require('.');
|
||||
const categories = require('../categories');
|
||||
const posts = require('../posts');
|
||||
const activitypub = require('../activitypub');
|
||||
const utils = require('../utils');
|
||||
|
||||
const Crossposts = module.exports;
|
||||
|
||||
Crossposts.get = async function (tid) {
|
||||
const crosspostIds = await db.getSortedSetMembers(`tid:${tid}:crossposts`);
|
||||
let crossposts = await db.getObjects(crosspostIds.map(id => `crosspost:${id}`));
|
||||
const cids = crossposts.reduce((cids, crossposts) => {
|
||||
cids.add(crossposts.cid);
|
||||
return cids;
|
||||
}, new Set());
|
||||
let categoriesData = await categories.getCategoriesFields(
|
||||
cids, ['cid', 'name', 'icon', 'bgColor', 'color', 'slug']
|
||||
);
|
||||
categoriesData = categoriesData.reduce((map, category) => {
|
||||
map.set(parseInt(category.cid, 10), category);
|
||||
return map;
|
||||
}, new Map());
|
||||
crossposts = crossposts.map((crosspost, idx) => {
|
||||
crosspost.id = crosspostIds[idx];
|
||||
crosspost.category = categoriesData.get(parseInt(crosspost.cid, 10));
|
||||
return crosspost;
|
||||
});
|
||||
|
||||
return crossposts;
|
||||
};
|
||||
|
||||
Crossposts.add = async function (tid, cid, uid) {
|
||||
// Target cid must exist
|
||||
if (!utils.isNumber(cid)) {
|
||||
await activitypub.actors.assert(cid);
|
||||
}
|
||||
const exists = await categories.exists(cid);
|
||||
if (!exists) {
|
||||
throw new Error('[[error:invalid-cid]]');
|
||||
}
|
||||
|
||||
const crossposts = await Crossposts.get(tid);
|
||||
const crosspostedCids = crossposts.map(crosspost => String(crosspost.cid));
|
||||
const now = Date.now();
|
||||
const crosspostId = utils.generateUUID();
|
||||
if (!crosspostedCids.includes(String(cid))) {
|
||||
const [topicData, pids] = await Promise.all([
|
||||
topics.getTopicFields(tid, ['uid', 'cid', 'timestamp']),
|
||||
topics.getPids(tid),
|
||||
]);
|
||||
let pidTimestamps = await posts.getPostsFields(pids, ['timestamp']);
|
||||
pidTimestamps = pidTimestamps.map(({ timestamp }) => timestamp);
|
||||
|
||||
if (cid === topicData.cid) {
|
||||
throw new Error('[[error:invalid-cid]]');
|
||||
}
|
||||
const zsets = [
|
||||
`cid:${topicData.cid}:tids`,
|
||||
`cid:${topicData.cid}:tids:create`,
|
||||
`cid:${topicData.cid}:tids:lastposttime`,
|
||||
`cid:${topicData.cid}:uid:${topicData.uid}:tids`,
|
||||
`cid:${topicData.cid}:tids:votes`,
|
||||
`cid:${topicData.cid}:tids:posts`,
|
||||
`cid:${topicData.cid}:tids:views`,
|
||||
];
|
||||
const scores = await db.sortedSetsScore(zsets, tid);
|
||||
const bulkAdd = zsets.map((zset, idx) => {
|
||||
return [zset.replace(`cid:${topicData.cid}`, `cid:${cid}`), scores[idx], tid];
|
||||
});
|
||||
await Promise.all([
|
||||
db.sortedSetAddBulk(bulkAdd),
|
||||
db.sortedSetAdd(`cid:${cid}:pids`, pidTimestamps, pids),
|
||||
db.setObject(`crosspost:${crosspostId}`, { uid, tid, cid, timestamp: now }),
|
||||
db.sortedSetAdd(`tid:${tid}:crossposts`, now, crosspostId),
|
||||
db.sortedSetAdd(`uid:${uid}:crossposts`, now, crosspostId),
|
||||
]);
|
||||
await categories.onTopicsMoved([cid]);
|
||||
} else {
|
||||
throw new Error('[[error:topic-already-crossposted]]');
|
||||
}
|
||||
|
||||
return [...crossposts, { id: crosspostId, uid, tid, cid, timestamp: now }];
|
||||
};
|
||||
|
||||
Crossposts.remove = async function (tid, cid, uid) {
|
||||
let crossposts = await Crossposts.get(tid);
|
||||
const crosspostId = crossposts.reduce((id, { id: _id, cid: _cid, uid: _uid }) => {
|
||||
if (String(cid) === String(_cid) && String(uid) === String(_uid)) {
|
||||
id = _id;
|
||||
}
|
||||
|
||||
return id;
|
||||
}, null);
|
||||
if (!crosspostId) {
|
||||
throw new Error('[[error:invalid-data]]');
|
||||
}
|
||||
|
||||
const [author, pids] = await Promise.all([
|
||||
topics.getTopicField(tid, 'uid'),
|
||||
topics.getPids(tid),
|
||||
]);
|
||||
let bulkRemove = [
|
||||
`cid:${cid}:tids`,
|
||||
`cid:${cid}:tids:create`,
|
||||
`cid:${cid}:tids:lastposttime`,
|
||||
`cid:${cid}:uid:${author}:tids`,
|
||||
`cid:${cid}:tids:votes`,
|
||||
`cid:${cid}:tids:posts`,
|
||||
`cid:${cid}:tids:views`,
|
||||
];
|
||||
bulkRemove = bulkRemove.map(zset => [zset, tid]);
|
||||
bulkRemove.push([`cid:${cid}:pids`, pids]);
|
||||
|
||||
await Promise.all([
|
||||
db.sortedSetRemoveBulk(bulkRemove),
|
||||
db.delete(`crosspost:${crosspostId}`),
|
||||
db.sortedSetRemove(`tid:${tid}:crossposts`, crosspostId),
|
||||
db.sortedSetRemove(`uid:${uid}:crossposts`, crosspostId),
|
||||
]);
|
||||
await categories.onTopicsMoved([cid]);
|
||||
|
||||
crossposts = await Crossposts.get(tid);
|
||||
return crossposts;
|
||||
};
|
||||
@@ -35,6 +35,7 @@ Topics.thumbs = require('./thumbs');
|
||||
require('./bookmarks')(Topics);
|
||||
require('./merge')(Topics);
|
||||
Topics.events = require('./events');
|
||||
Topics.crossposts = require('./crossposts');
|
||||
|
||||
Topics.exists = async function (tids) {
|
||||
return await db.exists(
|
||||
|
||||
@@ -5,9 +5,11 @@ const _ = require('lodash');
|
||||
const db = require('../database');
|
||||
const topics = require('.');
|
||||
const categories = require('../categories');
|
||||
const posts = require('../posts');
|
||||
const user = require('../user');
|
||||
const plugins = require('../plugins');
|
||||
const privileges = require('../privileges');
|
||||
const activitypub = require('../activitypub');
|
||||
const utils = require('../utils');
|
||||
|
||||
|
||||
@@ -233,7 +235,7 @@ module.exports = function (Topics) {
|
||||
};
|
||||
|
||||
topicTools.move = async function (tid, data) {
|
||||
const cid = utils.isNumber(data.cid) ? parseInt(data.cid, 10) : data.cid;
|
||||
const cid = parseInt(data.cid, 10);
|
||||
const topicData = await Topics.getTopicData(tid);
|
||||
if (!topicData) {
|
||||
throw new Error('[[error:no-topic]]');
|
||||
@@ -241,6 +243,10 @@ module.exports = function (Topics) {
|
||||
if (cid === topicData.cid) {
|
||||
throw new Error('[[error:cant-move-topic-to-same-category]]');
|
||||
}
|
||||
if (!utils.isNumber(cid) || !utils.isNumber(topicData.cid)) {
|
||||
throw new Error('[[error:cant-move-topic-to-from-remote-categories]]');
|
||||
}
|
||||
|
||||
const tags = await Topics.getTopicTags(tid);
|
||||
await db.sortedSetsRemove([
|
||||
`cid:${topicData.cid}:tids`,
|
||||
|
||||
@@ -290,7 +290,7 @@ module.exports = function (Topics) {
|
||||
};
|
||||
|
||||
Topics.markAsRead = async function (tids, uid) {
|
||||
if (!Array.isArray(tids) || !tids.length) {
|
||||
if (!Array.isArray(tids) || !tids.length || !utils.isNumber(uid)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
24
src/upgrades/4.7.1/remove_extraneous_ap_data.js
Normal file
24
src/upgrades/4.7.1/remove_extraneous_ap_data.js
Normal file
@@ -0,0 +1,24 @@
|
||||
'use strict';
|
||||
|
||||
const db = require('../../database');
|
||||
const batch = require('../../batch');
|
||||
|
||||
module.exports = {
|
||||
name: 'Remove extraneous upvote and tids_read data for remote users',
|
||||
timestamp: Date.UTC(2025, 11, 11),
|
||||
method: async function () {
|
||||
const { progress } = this;
|
||||
await batch.processSortedSet('usersRemote:lastCrawled', async (uids) => {
|
||||
const readKeys = uids.map(uid => `uid:${uid}:tids_read`);
|
||||
const voteKeys = uids.map(uid => `uid:${uid}:upvote`);
|
||||
|
||||
const combined = readKeys.concat(voteKeys);
|
||||
|
||||
await db.deleteAll(combined);
|
||||
progress.incr(uids.length);
|
||||
}, {
|
||||
batch: 500,
|
||||
progress,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -16,12 +16,14 @@
|
||||
</div>
|
||||
|
||||
<ul component="category/list" class="list-unstyled mb-0 text-sm category-dropdown-menu ghost-scrollbar" role="menu">
|
||||
{{{ if !hideAll }}}
|
||||
<li role="presentation" class="category" data-cid="all">
|
||||
<a class="dropdown-item rounded-1 d-flex align-items-center gap-2" role="menuitem" href="{{{ if allCategoriesUrl }}}{config.relative_path}/{allCategoriesUrl}{{{ else }}}#{{{ end }}}">
|
||||
<div class="flex-grow-1">[[unread:all-categories]]</div>
|
||||
<i component="category/select/icon" class="flex-shrink-0 fa fa-fw fa-check {{{if selectedCategory}}}invisible{{{end}}}"></i>
|
||||
</a>
|
||||
</li>
|
||||
{{{ end }}}
|
||||
{{{each categoryItems}}}
|
||||
<li role="presentation" class="category {{{ if ./disabledClass }}}disabled{{{ end }}}" data-cid="{./cid}" data-parent-cid="{./parentCid}" data-name="{./name}">
|
||||
<a class="dropdown-item rounded-1 d-flex align-items-center gap-2 {{{ if ./disabledClass }}}disabled{{{ end }}}" role="menuitem" href="#">
|
||||
|
||||
@@ -15,9 +15,15 @@
|
||||
<a component="topic/unpin" href="#" class="dropdown-item rounded-1 d-flex align-items-center gap-2 {{{ if !pinned }}}hidden{{{ end }}}" role="menuitem"><i class="fa fa-fw fa-thumb-tack fa-rotate-90 text-secondary"></i> [[topic:thread-tools.unpin]]</a>
|
||||
</li>
|
||||
|
||||
{{{ if isNumber(cid) }}}
|
||||
<li>
|
||||
<a component="topic/move" href="#" class="dropdown-item rounded-1 d-flex align-items-center gap-2" role="menuitem"><i class="fa fa-fw fa-arrows text-secondary"></i> [[topic:thread-tools.move]]</a>
|
||||
</li>
|
||||
{{{ end }}}
|
||||
|
||||
<li>
|
||||
<a component="topic/crosspost" href="#" class="dropdown-item rounded-1 d-flex align-items-center gap-2" role="menuitem"><i class="fa fa-fw fa-clone text-secondary"></i> [[topic:thread-tools.crosspost]]</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a component="topic/merge" href="#" class="dropdown-item rounded-1 d-flex align-items-center gap-2" role="menuitem"><i class="fa fa-fw fa-code-fork text-secondary"></i> [[topic:thread-tools.merge]]</a>
|
||||
|
||||
@@ -164,6 +164,7 @@ describe('FEPs', () => {
|
||||
});
|
||||
pid = id;
|
||||
({ activity } = await helpers.mocks.create(note));
|
||||
|
||||
await activitypub.inbox.create({ body: activity });
|
||||
|
||||
const activities = Array.from(activitypub._sent);
|
||||
@@ -180,7 +181,6 @@ describe('FEPs', () => {
|
||||
return activity.type === 'Announce' &&
|
||||
activity.object && activity.object.type === 'Note';
|
||||
});
|
||||
|
||||
assert(test1 && test2);
|
||||
});
|
||||
|
||||
|
||||
@@ -432,6 +432,7 @@ describe('Notes', () => {
|
||||
|
||||
describe('Create', () => {
|
||||
let uid;
|
||||
let cid;
|
||||
|
||||
before(async () => {
|
||||
uid = await user.create({ username: utils.generateUUID() });
|
||||
@@ -451,6 +452,17 @@ describe('Notes', () => {
|
||||
assert.strictEqual(cid, -1);
|
||||
});
|
||||
|
||||
it('should not append to the tids_read sorted set', async () => {
|
||||
const { note, id } = helpers.mocks.note();
|
||||
const { activity } = helpers.mocks.create(note);
|
||||
|
||||
await db.sortedSetAdd(`followersRemote:${note.attributedTo}`, Date.now(), uid);
|
||||
await activitypub.inbox.create({ body: activity });
|
||||
|
||||
const exists = await db.exists(`uid:${note.attributedTo}:tids_read`);
|
||||
assert(!exists);
|
||||
});
|
||||
|
||||
it('should create a new topic in a remote category if addressed (category same-origin)', async () => {
|
||||
const { id: remoteCid } = helpers.mocks.group();
|
||||
const { note, id } = helpers.mocks.note({
|
||||
@@ -467,40 +479,63 @@ describe('Notes', () => {
|
||||
});
|
||||
|
||||
it('should create a new topic in cid -1 if a non-same origin remote category is addressed', async function () {
|
||||
this.timeout(30000);
|
||||
const start = Date.now();
|
||||
const { id: remoteCid } = helpers.mocks.group({
|
||||
id: `https://example.com/${utils.generateUUID()}`,
|
||||
});
|
||||
console.log('1', Date.now() - start);
|
||||
const { note, id } = helpers.mocks.note({
|
||||
audience: [remoteCid],
|
||||
});
|
||||
console.log('2', Date.now() - start);
|
||||
const { activity } = helpers.mocks.create(note);
|
||||
console.log('3', Date.now() - start);
|
||||
try {
|
||||
await activitypub.inbox.create({ body: activity });
|
||||
} catch (err) {
|
||||
console.log('error in test', err.stack);
|
||||
assert(false);
|
||||
}
|
||||
|
||||
console.log('4', Date.now() - start);
|
||||
assert(await posts.exists(id));
|
||||
console.log('5', Date.now() - start);
|
||||
const cid = await posts.getCidByPid(id);
|
||||
console.log('6', Date.now() - start);
|
||||
assert.strictEqual(cid, -1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('(Like)', () => {
|
||||
let pid;
|
||||
let voterUid;
|
||||
|
||||
before(async () => {
|
||||
({ cid } = await categories.create({ name: utils.generateUUID() }));
|
||||
const { postData } = await topics.post({
|
||||
uid,
|
||||
cid,
|
||||
title: utils.generateUUID(),
|
||||
content: utils.generateUUID(),
|
||||
});
|
||||
pid = postData.pid;
|
||||
const object = await activitypub.mocks.notes.public(postData);
|
||||
const { activity } = helpers.mocks.like({ object });
|
||||
voterUid = activity.actor;
|
||||
await activitypub.inbox.like({ body: activity });
|
||||
});
|
||||
|
||||
it('should increment a like for the post', async () => {
|
||||
const voted = await posts.hasVoted(pid, voterUid);
|
||||
const count = await posts.getPostField(pid, 'upvotes');
|
||||
assert(voted);
|
||||
assert.strictEqual(count, 1);
|
||||
});
|
||||
|
||||
it('should not append to the uid upvotes zset', async () => {
|
||||
const exists = await db.exists(`uid:${voterUid}:upvote`);
|
||||
assert(!exists);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Announce', () => {
|
||||
let cid;
|
||||
|
||||
before(async () => {
|
||||
({ cid } = await categories.create({ name: utils.generateUUID().slice(0, 8) }));
|
||||
({ cid } = await categories.create({ name: utils.generateUUID() }));
|
||||
});
|
||||
|
||||
describe('(Create)', () => {
|
||||
|
||||
@@ -203,4 +203,241 @@ describe('Privilege logic for remote users/content (ActivityPub)', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Privilege masking', () => {
|
||||
before(async () => {
|
||||
// Grant default fediverse privileges
|
||||
await install.giveWorldPrivileges();
|
||||
});
|
||||
|
||||
describe('control', () => {
|
||||
let cid;
|
||||
let uid;
|
||||
|
||||
before(async () => {
|
||||
// Set up a standard mock group
|
||||
({ id: cid } = helpers.mocks.group());
|
||||
|
||||
// Unprivileged user for testing
|
||||
uid = await user.create({ username: utils.generateUUID().slice(0, 10) });
|
||||
});
|
||||
|
||||
it('should properly assert the remote category', async () => {
|
||||
const assertion = await activitypub.actors.assertGroup([cid]);
|
||||
const exists = await categories.exists(cid);
|
||||
|
||||
assert(assertion);
|
||||
assert(exists);
|
||||
});
|
||||
|
||||
it('should not set up a privilege mask for that category', async () => {
|
||||
const exists = await db.exists(`cid:${cid}:privilegeMask`);
|
||||
assert(!exists);
|
||||
});
|
||||
|
||||
it('should pass the privileges .can() check if requested', async () => {
|
||||
const set = await privileges.categories.get(cid, uid);
|
||||
const can = await privileges.categories.can('topics:create', cid, uid);
|
||||
assert(can);
|
||||
});
|
||||
|
||||
it('should return true in the privilege set when requested', async () => {
|
||||
const set = await privileges.categories.get(cid, uid);
|
||||
|
||||
assert(set);
|
||||
assert(set['topics:create']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('postingRestrictedToMods (true on assert)', () => {
|
||||
let cid;
|
||||
let uid;
|
||||
|
||||
before(async () => {
|
||||
// Set up a mock group with `postingRestrictedToMods` bit set
|
||||
({ id: cid } = helpers.mocks.group({
|
||||
postingRestrictedToMods: true,
|
||||
}));
|
||||
|
||||
// Unprivileged user for testing
|
||||
uid = await user.create({ username: utils.generateUUID().slice(0, 10) });
|
||||
});
|
||||
|
||||
it('should properly assert the remote category', async () => {
|
||||
const assertion = await activitypub.actors.assertGroup([cid]);
|
||||
const exists = await categories.exists(cid);
|
||||
|
||||
assert(assertion);
|
||||
assert(exists);
|
||||
});
|
||||
|
||||
it('should set up a privilege mask for that category', async () => {
|
||||
const exists = await db.exists(`cid:${cid}:privilegeMask`);
|
||||
assert(exists);
|
||||
});
|
||||
|
||||
it('should contain a single mask', async () => {
|
||||
const members = await db.getSetMembers(`cid:${cid}:privilegeMask`);
|
||||
|
||||
assert(members);
|
||||
assert.strictEqual(members.length, 1);
|
||||
assert.strictEqual(members[0], 'topics:create');
|
||||
});
|
||||
|
||||
it('should fail the privileges .can() check if requested', async () => {
|
||||
const can = await privileges.categories.can('topics:create', cid, uid);
|
||||
assert(!can);
|
||||
});
|
||||
|
||||
it('should return false in the privilege set when requested', async () => {
|
||||
const set = await privileges.categories.get(cid, uid);
|
||||
|
||||
assert(set);
|
||||
assert(!set['topics:create']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('postingRestrictedToMods (true on assert and re-assertion)', () => {
|
||||
let cid;
|
||||
let uid;
|
||||
|
||||
before(async () => {
|
||||
// Set up a mock group with `postingRestrictedToMods` bit set
|
||||
({ id: cid } = helpers.mocks.group({
|
||||
postingRestrictedToMods: true,
|
||||
}));
|
||||
|
||||
// Unprivileged user for testing
|
||||
uid = await user.create({ username: utils.generateUUID().slice(0, 10) });
|
||||
|
||||
// Assert group, then re-assert again
|
||||
await activitypub.actors.assertGroup([cid]);
|
||||
await activitypub.actors.assertGroup([cid], { update: true });
|
||||
});
|
||||
|
||||
it('should fail the privileges .can() check if requested', async () => {
|
||||
const can = await privileges.categories.can('topics:create', cid, uid);
|
||||
assert(!can);
|
||||
});
|
||||
|
||||
it('should return false in the privilege set when requested', async () => {
|
||||
const set = await privileges.categories.get(cid, uid);
|
||||
|
||||
assert(set);
|
||||
assert(!set['topics:create']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('postingRestrictedToMods (true on assert, false on update)', () => {
|
||||
let cid;
|
||||
let uid;
|
||||
|
||||
before(async () => {
|
||||
// Set up a mock group with `postingRestrictedToMods` bit set
|
||||
({ id: cid } = helpers.mocks.group({
|
||||
postingRestrictedToMods: true,
|
||||
}));
|
||||
|
||||
// Unprivileged user for testing
|
||||
uid = await user.create({ username: utils.generateUUID().slice(0, 10) });
|
||||
|
||||
await activitypub.actors.assertGroup([cid]);
|
||||
|
||||
// Group updated "remotely"
|
||||
helpers.mocks.group({
|
||||
id: cid,
|
||||
postingRestrictedToMods: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove the privilege mask if the bit is not present on group actor update', async () => {
|
||||
let can = await privileges.categories.can('topics:create', cid, uid);
|
||||
assert(!can, 'Initial state should be denied due to mask.');
|
||||
|
||||
// Group re-assertion
|
||||
await activitypub.actors.assertGroup([cid], { update: true });
|
||||
|
||||
// Ensure mask is gone from db
|
||||
const memberCount = await db.setCount(`cid:${cid}:privilegeMask`);
|
||||
assert.strictEqual(memberCount, 0);
|
||||
|
||||
can = await privileges.categories.can('topics:create', cid, uid);
|
||||
assert(can, 'Privilege should be restored after mask removal.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('postingRestrictedToMods (true on assert, property missing on update)', () => {
|
||||
let cid;
|
||||
let uid;
|
||||
|
||||
before(async () => {
|
||||
// Set up a mock group with `postingRestrictedToMods` bit set
|
||||
({ id: cid } = helpers.mocks.group({
|
||||
postingRestrictedToMods: true,
|
||||
}));
|
||||
|
||||
// Unprivileged user for testing
|
||||
uid = await user.create({ username: utils.generateUUID().slice(0, 10) });
|
||||
|
||||
await activitypub.actors.assertGroup([cid]);
|
||||
|
||||
// Group updated "remotely"
|
||||
helpers.mocks.group({
|
||||
id: cid,
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove the privilege mask if the bit is not present on group actor update', async () => {
|
||||
let can = await privileges.categories.can('topics:create', cid, uid);
|
||||
assert(!can, 'Initial state should be denied due to mask.');
|
||||
|
||||
// Group re-assertion
|
||||
await activitypub.actors.assertGroup([cid], { update: true });
|
||||
|
||||
// Ensure mask is gone from db
|
||||
const memberCount = await db.setCount(`cid:${cid}:privilegeMask`);
|
||||
assert.strictEqual(memberCount, 0);
|
||||
|
||||
can = await privileges.categories.can('topics:create', cid, uid);
|
||||
assert(can, 'Privilege should be restored after mask removal.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('postingRestrictedToMods (false on assert, true on update)', () => {
|
||||
let cid;
|
||||
let uid;
|
||||
|
||||
before(async () => {
|
||||
// Set up a mock group with `postingRestrictedToMods` bit set to false
|
||||
({ id: cid } = helpers.mocks.group({
|
||||
postingRestrictedToMods: false,
|
||||
}));
|
||||
|
||||
// Unprivileged user for testing
|
||||
uid = await user.create({ username: utils.generateUUID().slice(0, 10) });
|
||||
|
||||
await activitypub.actors.assertGroup([cid]);
|
||||
|
||||
|
||||
// Update group "remotely", re-assert
|
||||
helpers.mocks.group({
|
||||
id: cid,
|
||||
postingRestrictedToMods: true,
|
||||
});
|
||||
await activitypub.actors.assertGroup([cid], { update: true });
|
||||
});
|
||||
|
||||
it('should fail the privileges .can() check if requested', async () => {
|
||||
const can = await privileges.categories.can('topics:create', cid, uid);
|
||||
assert(!can);
|
||||
});
|
||||
|
||||
it('should return false in the privilege set when requested', async () => {
|
||||
const set = await privileges.categories.get(cid, uid);
|
||||
|
||||
assert(set);
|
||||
assert(!set['topics:create']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -659,10 +659,15 @@ describe('API', async () => {
|
||||
case 'boolean':
|
||||
assert.strictEqual(typeof response[prop], 'boolean', `"${prop}" was expected to be a boolean, but was ${typeof response[prop]} instead (path: ${method} ${path}, context: ${context})`);
|
||||
break;
|
||||
case 'object':
|
||||
assert.strictEqual(typeof response[prop], 'object', `"${prop}" was expected to be an object, but was ${typeof response[prop]} instead (path: ${method} ${path}, context: ${context})`);
|
||||
case 'object': {
|
||||
let valid = ['object'];
|
||||
if (schema[prop].additionalProperties && schema[prop].additionalProperties.oneOf) {
|
||||
valid = schema[prop].additionalProperties.oneOf.map(({ type }) => type);
|
||||
}
|
||||
assert(valid.includes(typeof response[prop]), `"${prop}" was expected to be an object, but was ${typeof response[prop]} instead (path: ${method} ${path}, context: ${context})`);
|
||||
compare(schema[prop], response[prop], method, path, context ? [context, prop].join('.') : prop);
|
||||
break;
|
||||
}
|
||||
case 'array':
|
||||
assert.strictEqual(Array.isArray(response[prop]), true, `"${prop}" was expected to be an array, but was ${typeof response[prop]} instead (path: ${method} ${path}, context: ${context})`);
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
'use strict';
|
||||
|
||||
const db = require('./mocks/databasemock');
|
||||
|
||||
const assert = require('assert');
|
||||
const nconf = require('nconf');
|
||||
|
||||
const request = require('../src/request');
|
||||
const db = require('./mocks/databasemock');
|
||||
const Categories = require('../src/categories');
|
||||
const Topics = require('../src/topics');
|
||||
const User = require('../src/user');
|
||||
|
||||
@@ -88,6 +88,36 @@ describe('Set methods', () => {
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should add the values to each set', async () => {
|
||||
await db.setsAdd(['saddarray1', 'saddarray2', 'saddarray3'], ['v1', 'v2', 'v3']);
|
||||
const data = await db.getSetsMembers(['saddarray1', 'saddarray2', 'saddarray3']);
|
||||
data.forEach(members => members.sort());
|
||||
assert.deepStrictEqual(data, [
|
||||
['v1', 'v2', 'v3'],
|
||||
['v1', 'v2', 'v3'],
|
||||
['v1', 'v2', 'v3'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setAddBulk()', () => {
|
||||
it('should add multiple key-member pairs', async () => {
|
||||
await db.setAddBulk([
|
||||
['bulkSet1', 'value1'],
|
||||
['bulkSet2', 'value2'],
|
||||
]);
|
||||
let data = await db.getSetMembers('bulkSet1');
|
||||
assert.deepStrictEqual(data, ['value1']);
|
||||
data = await db.getSetMembers('bulkSet2');
|
||||
assert.deepStrictEqual(data, ['value2']);
|
||||
await db.setAddBulk([
|
||||
['bulkSet1', 'value1'],
|
||||
['bulkSet1', 'value3'],
|
||||
]);
|
||||
data = await db.getSetMembers('bulkSet1');
|
||||
assert.deepStrictEqual(data.sort(), ['value1', 'value3']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSetsMembers()', () => {
|
||||
@@ -208,57 +238,42 @@ describe('Set methods', () => {
|
||||
});
|
||||
|
||||
describe('setRemove()', () => {
|
||||
before((done) => {
|
||||
db.setAdd('testSet6', [1, 2], done);
|
||||
it('should remove an element from set', async () => {
|
||||
await db.setAdd('testSet6', [1, 2]);
|
||||
await db.setRemove('testSet6', '2');
|
||||
|
||||
const isMember = await db.isSetMember('testSet6', '2');
|
||||
assert.equal(isMember, false);
|
||||
});
|
||||
|
||||
it('should remove a element from set', (done) => {
|
||||
db.setRemove('testSet6', '2', function (err) {
|
||||
assert.equal(err, null);
|
||||
assert.equal(arguments.length, 1);
|
||||
it('should remove multiple elements from set', async () => {
|
||||
await db.setAdd('multiRemoveSet', [1, 2, 3, 4, 5]);
|
||||
await db.setRemove('multiRemoveSet', [1, 3, 5]);
|
||||
|
||||
db.isSetMember('testSet6', '2', (err, isMember) => {
|
||||
assert.equal(err, null);
|
||||
assert.equal(isMember, false);
|
||||
done();
|
||||
});
|
||||
});
|
||||
const members = await db.getSetMembers('multiRemoveSet');
|
||||
assert(members.includes('2'));
|
||||
assert(members.includes('4'));
|
||||
});
|
||||
|
||||
it('should remove multiple elements from set', (done) => {
|
||||
db.setAdd('multiRemoveSet', [1, 2, 3, 4, 5], (err) => {
|
||||
assert.ifError(err);
|
||||
db.setRemove('multiRemoveSet', [1, 3, 5], (err) => {
|
||||
assert.ifError(err);
|
||||
db.getSetMembers('multiRemoveSet', (err, members) => {
|
||||
assert.ifError(err);
|
||||
assert(members.includes('2'));
|
||||
assert(members.includes('4'));
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
it('should remove multiple values from multiple keys', async () => {
|
||||
await db.setAdd('multiSetTest1', ['one', 'two', 'three', 'four']);
|
||||
await db.setAdd('multiSetTest2', ['three', 'four', 'five', 'six']);
|
||||
await db.setRemove(['multiSetTest1', 'multiSetTest2'], ['three', 'four', 'five', 'doesnt exist']);
|
||||
|
||||
const members = await db.getSetsMembers(['multiSetTest1', 'multiSetTest2']);
|
||||
assert.equal(members[0].length, 2);
|
||||
assert.equal(members[1].length, 1);
|
||||
assert(members[0].includes('one'));
|
||||
assert(members[0].includes('two'));
|
||||
assert(members[1].includes('six'));
|
||||
});
|
||||
|
||||
it('should remove multiple values from multiple keys', (done) => {
|
||||
db.setAdd('multiSetTest1', ['one', 'two', 'three', 'four'], (err) => {
|
||||
assert.ifError(err);
|
||||
db.setAdd('multiSetTest2', ['three', 'four', 'five', 'six'], (err) => {
|
||||
assert.ifError(err);
|
||||
db.setRemove(['multiSetTest1', 'multiSetTest2'], ['three', 'four', 'five', 'doesnt exist'], (err) => {
|
||||
assert.ifError(err);
|
||||
db.getSetsMembers(['multiSetTest1', 'multiSetTest2'], (err, members) => {
|
||||
assert.ifError(err);
|
||||
assert.equal(members[0].length, 2);
|
||||
assert.equal(members[1].length, 1);
|
||||
assert(members[0].includes('one'));
|
||||
assert(members[0].includes('two'));
|
||||
assert(members[1].includes('six'));
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
it('should remove set if all elements are removed', async () => {
|
||||
await db.setAdd('toBeDeletedSet', ['a', 'b']);
|
||||
await db.setRemove('toBeDeletedSet', ['a', 'b']);
|
||||
|
||||
const exists = await db.exists('toBeDeletedSet');
|
||||
assert.equal(exists, false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
160
test/topics/crossposts.js
Normal file
160
test/topics/crossposts.js
Normal file
@@ -0,0 +1,160 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
|
||||
const db = require('../mocks/databasemock');
|
||||
|
||||
const user = require('../../src/user');
|
||||
const categories = require('../../src/categories');
|
||||
const topics = require('../../src/topics');
|
||||
const utils = require('../../src/utils');
|
||||
|
||||
describe('Crossposting (& related logic)', () => {
|
||||
describe('topic already in multiple categories', () => {
|
||||
let tid;
|
||||
let cid1;
|
||||
let cid2;
|
||||
let uid;
|
||||
|
||||
before(async () => {
|
||||
({ cid: cid1 } = await categories.create({ name: utils.generateUUID().slice(0, 8) }));
|
||||
uid = await user.create({ username: utils.generateUUID().slice(0, 8) });
|
||||
const { topicData } = await topics.post({
|
||||
uid,
|
||||
cid: cid1,
|
||||
title: utils.generateUUID(),
|
||||
content: utils.generateUUID(),
|
||||
});
|
||||
tid = topicData.tid;
|
||||
|
||||
// Add topic to another category's zset
|
||||
const crosspostCategory = await categories.create({ name: utils.generateUUID().slice(0, 8) });
|
||||
cid2 = crosspostCategory.cid;
|
||||
await db.sortedSetAdd(`cid:${crosspostCategory.cid}:tids`, topicData.timestamp, tid);
|
||||
});
|
||||
|
||||
it('should contain the topic in both categories when requested', async () => {
|
||||
const tids1 = await categories.getTopicIds({
|
||||
uid,
|
||||
cid: cid1,
|
||||
start: 0,
|
||||
stop: 1,
|
||||
});
|
||||
|
||||
const tids2 = await categories.getTopicIds({
|
||||
uid,
|
||||
cid: cid2,
|
||||
start: 0,
|
||||
stop: 1,
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(tids1, tids2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('crosspost', () => {
|
||||
let tid;
|
||||
let cid1;
|
||||
let cid2;
|
||||
let uid;
|
||||
|
||||
before(async () => {
|
||||
({ cid: cid1 } = await categories.create({ name: utils.generateUUID().slice(0, 8) }));
|
||||
const crosspostCategory = await categories.create({ name: utils.generateUUID().slice(0, 8) });
|
||||
cid2 = crosspostCategory.cid;
|
||||
uid = await user.create({ username: utils.generateUUID().slice(0, 8) });
|
||||
const { topicData } = await topics.post({
|
||||
uid,
|
||||
cid: cid1,
|
||||
title: utils.generateUUID(),
|
||||
content: utils.generateUUID(),
|
||||
});
|
||||
tid = topicData.tid;
|
||||
});
|
||||
|
||||
it('should successfully crosspost to another cid', async () => {
|
||||
const crossposts = await topics.crossposts.add(tid, cid2, uid);
|
||||
|
||||
assert(Array.isArray(crossposts));
|
||||
assert.strictEqual(crossposts.length, 1);
|
||||
assert.partialDeepStrictEqual(crossposts[0], {
|
||||
uid,
|
||||
tid,
|
||||
cid: cid2,
|
||||
});
|
||||
});
|
||||
|
||||
it('should show the tid in both categories when requested', async () => {
|
||||
const tids1 = await categories.getTopicIds({
|
||||
uid,
|
||||
cid: cid1,
|
||||
start: 0,
|
||||
stop: 1,
|
||||
});
|
||||
|
||||
const tids2 = await categories.getTopicIds({
|
||||
uid,
|
||||
cid: cid2,
|
||||
start: 0,
|
||||
stop: 1,
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(tids1, tids2);
|
||||
});
|
||||
|
||||
it('should throw on cross-posting again when already cross-posted', async () => {
|
||||
await assert.rejects(
|
||||
topics.crossposts.add(tid, cid2, uid),
|
||||
{ message: '[[error:topic-already-crossposted]]' },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uncrosspost', () => {
|
||||
let tid;
|
||||
let cid1;
|
||||
let cid2;
|
||||
let uid;
|
||||
|
||||
before(async () => {
|
||||
({ cid: cid1 } = await categories.create({ name: utils.generateUUID().slice(0, 8) }));
|
||||
const crosspostCategory = await categories.create({ name: utils.generateUUID().slice(0, 8) });
|
||||
cid2 = crosspostCategory.cid;
|
||||
uid = await user.create({ username: utils.generateUUID().slice(0, 8) });
|
||||
const { topicData } = await topics.post({
|
||||
uid,
|
||||
cid: cid1,
|
||||
title: utils.generateUUID(),
|
||||
content: utils.generateUUID(),
|
||||
});
|
||||
tid = topicData.tid;
|
||||
|
||||
await topics.crossposts.add(tid, cid2, uid);
|
||||
});
|
||||
|
||||
it('should successfully uncrosspost from a cid', async () => {
|
||||
const crossposts = await topics.crossposts.remove(tid, cid2, uid);
|
||||
|
||||
assert(Array.isArray(crossposts));
|
||||
assert.strictEqual(crossposts.length, 0);
|
||||
});
|
||||
|
||||
it('should not contain the topic in the category the topic was uncrossposted from', async () => {
|
||||
const tids = await categories.getTopicIds({
|
||||
uid,
|
||||
cid: cid2,
|
||||
start: 0,
|
||||
stop: 1,
|
||||
});
|
||||
|
||||
assert(!tids.includes(tid));
|
||||
});
|
||||
|
||||
it('should throw on uncrossposting if already uncrossposted', async () => {
|
||||
assert.rejects(
|
||||
topics.crossposts.remove(tid, cid2, uid),
|
||||
'[[error:invalid-data]]',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
109
test/topics/tools.js
Normal file
109
test/topics/tools.js
Normal file
@@ -0,0 +1,109 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
|
||||
const db = require('../mocks/databasemock');
|
||||
|
||||
const user = require('../../src/user');
|
||||
const categories = require('../../src/categories');
|
||||
const topics = require('../../src/topics');
|
||||
const utils = require('../../src/utils');
|
||||
|
||||
describe('Topic tools', () => {
|
||||
describe('Topic moving', () => {
|
||||
let cid1;
|
||||
let cid2;
|
||||
let tid;
|
||||
let uid;
|
||||
|
||||
before(async () => {
|
||||
({ cid: cid1 } = await categories.create({ name: utils.generateUUID().slice(0, 8) }));
|
||||
({ cid: cid2 } = await categories.create({ name: utils.generateUUID().slice(0, 8) }));
|
||||
|
||||
uid = await user.create({ username: utils.generateUUID().slice(0, 8) });
|
||||
const { topicData } = await topics.post({
|
||||
uid,
|
||||
cid: cid1,
|
||||
title: utils.generateUUID(),
|
||||
content: utils.generateUUID(),
|
||||
});
|
||||
tid = topicData.tid;
|
||||
});
|
||||
|
||||
it('should not error when moving a topic from one cid to another', async () => {
|
||||
await topics.tools.move(tid, {
|
||||
cid: cid2,
|
||||
uid,
|
||||
});
|
||||
});
|
||||
|
||||
it('should reflect the topic in the new category', async () => {
|
||||
const tids = await categories.getTopicIds({
|
||||
uid,
|
||||
cid: cid2,
|
||||
start: 0,
|
||||
stop: 1,
|
||||
});
|
||||
|
||||
assert(Array.isArray(tids));
|
||||
assert.deepStrictEqual(tids, [String(tid)]);
|
||||
});
|
||||
|
||||
it('should NOT reflect the topic in the old category', async () => {
|
||||
const tids = await categories.getTopicIds({
|
||||
uid,
|
||||
cid: cid1,
|
||||
start: 0,
|
||||
stop: 1,
|
||||
});
|
||||
|
||||
assert(Array.isArray(tids));
|
||||
assert.deepStrictEqual(tids, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with remote categories', () => {
|
||||
let remoteCid;
|
||||
let localCid;
|
||||
let tid1;
|
||||
let tid2;
|
||||
|
||||
before(async () => {
|
||||
const helpers = require('../activitypub/helpers');
|
||||
({ id: remoteCid } = helpers.mocks.group());
|
||||
({ cid: localCid } = await categories.create({ name: utils.generateUUID().slice(0, 8) }));
|
||||
|
||||
({ id: tid1 } = helpers.mocks.note({
|
||||
audience: remoteCid,
|
||||
}));
|
||||
const uid = await user.create({ username: utils.generateUUID().slice(0, 8) });
|
||||
const { topicData } = await topics.post({
|
||||
uid,
|
||||
cid: localCid,
|
||||
title: utils.generateUUID(),
|
||||
content: utils.generateUUID(),
|
||||
});
|
||||
tid2 = topicData.tid;
|
||||
});
|
||||
|
||||
it('should throw when attempting to move a topic from a remote category', async () => {
|
||||
assert.rejects(
|
||||
topics.tools.move(tid1, {
|
||||
cid: localCid,
|
||||
uid: 'system',
|
||||
}),
|
||||
'[[error:cant-move-topic-to-from-remote-categories]]'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when attempting to move a topic to a remote category', async () => {
|
||||
assert.rejects(
|
||||
topics.tools.move(tid2, {
|
||||
cid: remoteCid,
|
||||
uid: 'system',
|
||||
}),
|
||||
'[[error:cant-move-topic-to-from-remote-categories]]'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user