Compare commits

..

2 Commits

Author SHA1 Message Date
renovate[bot]
e9449f02a7 fix(deps): update dependency nodebb-theme-harmony to v2.1.29 2025-12-31 16:09:14 +00:00
renovate[bot]
89abdca179 fix(deps): update dependency @isaacs/ttlcache to v2.1.4 (#13861)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-31 11:08:26 -05:00
39 changed files with 160 additions and 1251 deletions

View File

@@ -33,7 +33,7 @@
"@fontsource/inter": "5.2.8",
"@fontsource/poppins": "5.2.7",
"@fortawesome/fontawesome-free": "6.7.2",
"@isaacs/ttlcache": "2.1.3",
"@isaacs/ttlcache": "2.1.4",
"@nodebb/spider-detector": "2.0.3",
"@popperjs/core": "2.11.8",
"@textcomplete/contenteditable": "0.1.13",
@@ -110,7 +110,7 @@
"nodebb-theme-harmony": "2.1.29",
"nodebb-theme-lavender": "7.1.19",
"nodebb-theme-peace": "2.2.49",
"nodebb-theme-persona": "14.1.21",
"nodebb-theme-persona": "14.1.20",
"nodebb-widget-essentials": "7.0.41",
"nodemailer": "7.0.12",
"nprogress": "0.2.0",

View File

@@ -5,6 +5,5 @@
"profile-page-for": "Profile page for user %1",
"user-watched-tags": "User watched tags",
"delete-upload-button": "Delete upload button",
"group-page-link-for": "Group page link for %1",
"show-crossposts": "Show Cross-posts"
"group-page-link-for": "Group page link for %1"
}

View File

@@ -170,8 +170,6 @@
"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.",
@@ -264,7 +262,6 @@
"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",

View File

@@ -81,7 +81,6 @@
"users": "Users",
"topics": "Topics",
"posts": "Posts",
"crossposts": "Cross-posts",
"x-posts": "<span class=\"formatted-number\">%1</span> posts",
"x-topics": "<span class=\"formatted-number\">%1</span> topics",
"x-reputation": "<span class=\"formatted-number\">%1</span> reputation",

View File

@@ -116,7 +116,6 @@
"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",
@@ -150,7 +149,6 @@
"load-categories": "Loading Categories",
"confirm-move": "Move",
"confirm-crosspost": "Cross-post",
"confirm-fork": "Fork",
"bookmark": "Bookmark",
@@ -163,7 +161,6 @@
"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",
@@ -187,10 +184,6 @@
"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.",
"crossposts.instructions": "Select one or more categories to cross-post to. Topic(s) will be accessible from the original category and all cross-posted categories.",
"crossposts.listing": "This topic has been cross-posted to the following local categories:",
"crossposts.none": "This topic has not been cross-posted to any additional categories",
"composer.title-placeholder": "Enter your topic title here...",
"composer.handle-placeholder": "Enter your name/handle here",
"composer.hide": "Hide",

View File

@@ -1,34 +0,0 @@
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'

View File

@@ -168,8 +168,6 @@ 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}:

View File

@@ -86,6 +86,7 @@ put:
privileges:
type: object
additionalProperties:
type: boolean
description: A set of privileges with either true or false
types:
type: object
@@ -102,6 +103,7 @@ put:
privileges:
type: object
additionalProperties:
type: boolean
description: A set of privileges with either true or false
types:
type: object

View File

@@ -47,6 +47,7 @@ get:
privileges:
type: object
additionalProperties:
type: boolean
description: A set of privileges with either true or false
isPrivate:
type: boolean
@@ -64,6 +65,7 @@ get:
privileges:
type: object
additionalProperties:
type: boolean
description: A set of privileges with either true or false
types:
type: object

View File

@@ -93,6 +93,7 @@ put:
privileges:
type: object
additionalProperties:
type: boolean
description: A set of privileges with either true or false
groups:
type: array
@@ -106,6 +107,7 @@ put:
privileges:
type: object
additionalProperties:
type: boolean
description: A set of privileges with either true or false
types:
type: object
@@ -228,6 +230,7 @@ delete:
privileges:
type: object
additionalProperties:
type: boolean
description: A set of privileges with either true or false
groups:
type: array
@@ -241,6 +244,7 @@ delete:
privileges:
type: object
additionalProperties:
type: boolean
description: A set of privileges with either true or false
types:
type: object

View File

@@ -71,4 +71,5 @@ get:
privileges:
type: object
additionalProperties:
type: boolean
description: A set of privileges with either true or false

View File

@@ -1,76 +0,0 @@
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

View File

@@ -69,7 +69,6 @@ define('forum/topic', [
setupQuickReply();
handleBookmark(tid);
handleThumbs();
addCrosspostsHandler();
$(window).on('scroll', utils.debounce(updateTopicTitle, 250));
@@ -407,22 +406,6 @@ define('forum/topic', [
});
}
function addCrosspostsHandler() {
const anchorEl = document.getElementById('show-crossposts');
if (anchorEl) {
anchorEl.addEventListener('click', async () => {
const { crossposts } = ajaxify.data;
const html = await app.parseAndTranslate('modals/crossposts', { crossposts });
bootbox.dialog({
onEscape: true,
backdrop: true,
title: '[[global:crossposts]]',
message: html,
});
});
}
}
function setupQuickReply() {
if (config.enableQuickReply || (config.theme && config.theme.enableQuickReply)) {
quickreply.init();

View File

@@ -1,154 +0,0 @@
'use strict';
define('forum/topic/crosspost', [
'categoryFilter', 'alerts', 'hooks', 'api', 'components',
], function (categoryFilter, alerts, hooks, api, components) {
const Crosspost = {};
let modal;
let selectedCids;
Crosspost.init = function (tid) {
if (modal) {
return;
}
Crosspost.tid = tid;
Crosspost.cid = ajaxify.data.cid;
Crosspost.current = ajaxify.data.crossposts;
showModal();
};
function showModal() {
app.parseAndTranslate('modals/crosspost-topic', {
selectedCategory: ajaxify.data.crossposts.length ?
{
icon: 'fa-plus',
name: '[[unread:multiple-categories-selected]]',
bgColor: '#ddd',
} :
ajaxify.data.category,
}, function (html) {
modal = html;
$('body').append(modal);
const dropdownEl = modal.find('[component="category-selector"]');
dropdownEl.addClass('dropup');
categoryFilter.init($('[component="category/dropdown"]'), {
onHidden: onCategoriesSelected,
hideAll: true,
hideUncategorized: true,
localOnly: true,
selectedCids: Array.from(new Set([ajaxify.data.cid, ...ajaxify.data.crossposts.map(c => c.cid)])),
});
modal.find('#crosspost_thread_commit').on('click', onCommitClicked);
modal.find('#crosspost_topic_cancel').on('click', closeCrosspostModal);
});
}
function onCategoriesSelected(data) {
selectedCids = data.selectedCids.filter(utils.isNumber);
if (data.changed) {
modal.find('#crosspost_thread_commit').prop('disabled', false);
}
}
function onCommitClicked() {
const commitEl = modal.find('#crosspost_thread_commit');
if (!commitEl.prop('disabled') && selectedCids && selectedCids.length) {
commitEl.prop('disabled', true);
const data = {
tid: Crosspost.tid,
cids: selectedCids,
};
// TODO
// if (config.undoTimeout > 0) {
// return alerts.alert({
// alert_id: 'tids_move_' + (Crosspost.tid ? Crosspost.tid.join('-') : 'all'),
// title: '[[topic:thread-tools.move]]',
// message: message,
// type: 'success',
// timeout: config.undoTimeout,
// timeoutfn: function () {
// moveTopics(data);
// },
// clickfn: function (alert, params) {
// delete params.timeoutfn;
// alerts.success('[[topic:topic-move-undone]]');
// },
// });
// }
crosspost(data);
}
}
function crosspost(data) {
hooks.fire('action:topic.crosspost', data);
const cids = data.cids.map((cid) => parseInt(cid, 10));
if (!cids.includes(Crosspost.cid)) {
cids.unshift(Crosspost.cid);
}
const current = [Crosspost.cid, ...Crosspost.current.map(x => parseInt(x.cid, 10))];
const add = cids.filter(cid => !current.includes(cid));
const remove = current.filter(cid => !cids.includes(cid));
const queries = [
...add.map((cid) => { return api.post(`/topics/${data.tid}/crossposts`, { cid }); }),
...remove.map((cid) => { return api.del(`/topics/${data.tid}/crossposts`, { cid }); }),
];
Promise.all(queries).then(async () => {
const statsEl = components.get('topic/stats');
updateSpinner('progress');
const { crossposts } = await api.get(`/topics/${data.tid}/crossposts`);
ajaxify.data.crossposts = crossposts;
const html = await app.parseAndTranslate('partials/topic/stats', ajaxify.data);
statsEl.html(html);
updateSpinner('success');
}).catch((e) => {
updateSpinner('error');
alerts.error(e);
});
}
const spinnerClasses = new Map(Object.entries({
'initial': ['d-none'],
'progress': ['fa-spinner', 'text-secondary', 'fa-spin'],
'error': ['fa-times', 'text-error'],
'success': ['fa-check', 'text-success'],
}));
function updateSpinner(state) {
if (modal) {
const spinnerEl = document.getElementById('crosspost_topic_spinner');
const remove = [
...spinnerClasses.get('initial'),
...spinnerClasses.get('progress'),
...spinnerClasses.get('error'),
...spinnerClasses.get('success'),
];
spinnerEl.classList.remove(...remove);
spinnerEl.classList.add(...spinnerClasses.get(state));
if (state !== 'initial') {
setTimeout(() => {
updateSpinner('initial');
}, 2500);
}
}
}
function closeCrosspostModal() {
if (modal) {
modal.remove();
modal = null;
}
}
return Crosspost;
});

View File

@@ -34,7 +34,6 @@ define('forum/topic/move', [
categorySelector.init(dropdownEl, {
onSelect: onCategorySelected,
privilege: 'moderate',
localOnly: true,
});
modal.find('#move_thread_commit').on('click', onCommitClicked);

View File

@@ -116,12 +116,6 @@ 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();

View File

@@ -76,8 +76,6 @@ 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);
@@ -95,7 +93,6 @@ 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());

View File

@@ -411,6 +411,7 @@ Actors.assertGroup = async (ids, options = {}) => {
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'),
]);
@@ -418,6 +419,41 @@ Actors.assertGroup = async (ids, options = {}) => {
return categoryObjs;
};
async function _migratePersonToGroup(categoryObjs) {
// 4.0.0-4.1.x asserted as:Group as users. This moves relevant stuff over and deletes the now-duplicate user.
let ids = categoryObjs.map(category => category.cid);
const slugs = categoryObjs.map(category => category.slug);
const isUser = await db.isObjectFields('handle:uid', slugs);
ids = ids.filter((id, idx) => isUser[idx]);
if (!ids.length) {
return;
}
await Promise.all(ids.map(async (id) => {
const shares = await db.getSortedSetMembers(`uid:${id}:shares`);
let cids = await topics.getTopicsFields(shares, ['cid']);
cids = cids.map(o => o.cid);
await Promise.all(shares.map(async (share, idx) => {
const cid = cids[idx];
if (cid === -1) {
await topics.tools.move(share, {
cid: id,
uid: 'system',
});
}
}));
const followers = await db.getSortedSetMembersWithScores(`followersRemote:${id}`);
await db.sortedSetAdd(
`cid:${id}:uid:watch:state`,
followers.map(() => categories.watchStates.tracking),
followers.map(({ value }) => value),
);
await user.deleteAccount(id);
}));
await categories.onTopicsMoved(ids);
}
Actors.getLocalFollowers = async (id) => {
// Returns local uids and cids that follow a remote actor (by id)
const response = {
@@ -445,19 +481,10 @@ Actors.getLocalFollowers = async (id) => {
}
});
} else if (isCategory) {
// Internally, users are different, they follow via watch state instead
// Possibly refactor to store in followersRemote:${id} too??
const members = await db.getSortedSetRangeByScore(`cid:${id}:uid:watch:state`, 0, -1, categories.watchStates.tracking, categories.watchStates.watching);
members.forEach((uid) => {
response.uids.add(uid);
});
const cids = await db.getSortedSetMembers(`followersRemote:${id}`);
cids.forEach((id) => {
if (id.startsWith('cid|') && utils.isNumber(id.slice(4))) {
response.cids.add(parseInt(id.slice(4), 10));
}
});
}
return response;

View File

@@ -388,10 +388,12 @@ inbox.announce = async (req) => {
// Category sync, remove when cross-posting available
const { cids } = await activitypub.actors.getLocalFollowers(actor);
const syncedCids = Array.from(cids);
let cid = null;
if (cids.size > 0) {
cid = Array.from(cids)[0];
}
// 1b12 announce
let cid = null;
const categoryActor = await categories.exists(actor);
if (categoryActor) {
cid = actor;
@@ -446,7 +448,7 @@ inbox.announce = async (req) => {
socketHelpers.sendNotificationToPostOwner(pid, actor, 'announce', 'notifications:activitypub.announce');
} else { // Remote object
// Follower check
if (!fromRelay && !cid && !syncedCids.length) {
if (!fromRelay && !cid) {
const { followers } = await activitypub.actors.getLocalFollowCounts(actor);
if (!followers) {
winston.verbose(`[activitypub/inbox.announce] Rejecting ${object.id} via ${actor} due to no followers`);
@@ -469,12 +471,6 @@ inbox.announce = async (req) => {
({ tid } = assertion);
await activitypub.notes.updateLocalRecipients(pid, { to, cc });
await activitypub.notes.syncUserInboxes(tid);
if (syncedCids) {
await Promise.all(syncedCids.map(async (cid) => {
await topics.crossposts.add(tid, cid, 0);
}));
}
}
if (!cid) { // Topic events from actors followed by users only

View File

@@ -104,7 +104,6 @@ Notes.assert = async (uid, input, options = { skipChecks: false }) => {
const hasTid = !!tid;
const cid = hasTid ? await topics.getTopicField(tid, 'cid') : options.cid || -1;
let crosspostCid = false;
if (options.cid && cid === -1) {
// Move topic if currently uncategorized
@@ -156,10 +155,8 @@ Notes.assert = async (uid, input, options = { skipChecks: false }) => {
}
// Auto-categorization (takes place only if all other categorization efforts fail)
crosspostCid = await assignCategory(mainPost);
if (!options.cid) {
options.cid = crosspostCid;
crosspostCid = false;
options.cid = await assignCategory(mainPost);
}
// mainPid ok to leave as-is
@@ -268,10 +265,6 @@ Notes.assert = async (uid, input, options = { skipChecks: false }) => {
await Notes.syncUserInboxes(tid, uid);
if (crosspostCid) {
await topics.crossposts.add(tid, crosspostCid, 0);
}
if (!hasTid && uid && options.cid) {
// New topic, have category announce it
await activitypub.out.announce.topic(tid);

View File

@@ -9,7 +9,6 @@ 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');
@@ -30,12 +29,9 @@ searchApi.categories = async (caller, data) => {
({ cids, matchedCids } = await findMatchedCids(caller.uid, data));
} else {
cids = await loadCids(caller.uid, data.parentCid);
if (!data.hideUncategorized && meta.config.activitypubEnabled) {
if (meta.config.activitypubEnabled) {
cids.unshift(-1);
}
if (data.localOnly) {
cids = cids.filter(cid => utils.isNumber(cid));
}
}
const visibleCategories = await controllersHelpers.getVisibleCategories({
@@ -70,7 +66,6 @@ async function findMatchedCids(uid, data) {
query: data.search,
qs: data.query,
paginate: false,
localOnly: data.localOnly,
});
let matchedCids = result.categories.map(c => c.cid);

View File

@@ -6,14 +6,12 @@ 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();
@@ -23,9 +21,6 @@ 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,

View File

@@ -123,9 +123,8 @@ topicsController.get = async function getTopic(req, res, next) {
p => parseInt(p.index, 10) === parseInt(Math.max(0, postIndex - 1), 10)
);
const [author, crossposts] = await Promise.all([
const [author] = 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),
@@ -135,7 +134,6 @@ 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}`;

View File

@@ -213,22 +213,3 @@ Topics.move = async (req, res) => {
helpers.formatApiResponse(200, res);
};
Topics.getCrossposts = async (req, res) => {
const crossposts = await topics.crossposts.get(req.params.tid);
helpers.formatApiResponse(200, res, { crossposts });
};
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 });
};

View File

@@ -54,9 +54,5 @@ module.exports = function () {
setupApiRoute(router, 'put', '/:tid/move', [...middlewares, middleware.assert.topic], controllers.write.topics.move);
setupApiRoute(router, 'get', '/:tid/crossposts', [...middlewares, middleware.assert.topic], controllers.write.topics.getCrossposts);
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;
};

View File

@@ -1,140 +0,0 @@
'use strict';
const db = require('../database');
const topics = require('.');
const user = require('../user');
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(
Array.from(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),
uid > 0 ? db.sortedSetAdd(`uid:${uid}:crossposts`, now, crosspostId) : false,
]);
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 isPrivileged = await user.isAdminOrGlobalMod(uid);
const isMod = await user.isModerator(uid, cid);
const crosspostId = crossposts.reduce((id, { id: _id, cid: _cid, uid: _uid }) => {
if (String(cid) === String(_cid) && (isPrivileged || isMod || 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),
uid > 0 ? db.sortedSetRemove(`uid:${uid}:crossposts`, crosspostId) : false,
]);
await categories.onTopicsMoved([cid]);
crossposts = await Crossposts.get(tid);
return crossposts;
};
Crossposts.removeAll = async function (tid) {
const crosspostIds = await db.getSortedSetMembers(`tid:${tid}:crossposts`);
const crossposts = await db.getObjects(crosspostIds.map(id => `crosspost:${id}`));
await Promise.all(crossposts.map(async ({ tid, cid, uid }) => {
return Crossposts.remove(tid, cid, uid);
}));
return [];
};

View File

@@ -102,7 +102,6 @@ module.exports = function (Topics) {
Topics.deleteTopicTags(tid),
Topics.events.purge(tid),
Topics.thumbs.deleteAll(tid),
Topics.crossposts.removeAll(tid),
reduceCounters(tid),
]);
plugins.hooks.fire('action:topic.purge', { topic: deletedTopic, uid: uid });

View File

@@ -35,7 +35,6 @@ 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(

View File

@@ -5,11 +5,9 @@ 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');
@@ -235,7 +233,7 @@ module.exports = function (Topics) {
};
topicTools.move = async function (tid, data) {
const cid = parseInt(data.cid, 10);
const cid = utils.isNumber(data.cid) ? parseInt(data.cid, 10) : data.cid;
const topicData = await Topics.getTopicData(tid);
if (!topicData) {
throw new Error('[[error:no-topic]]');
@@ -243,10 +241,6 @@ 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`,

View File

@@ -1,16 +0,0 @@
<div class="card tool-modal shadow">
<h5 class="card-header">
[[topic:crosspost-topic]]
</h5>
<div class="card-body">
<p>
[[topic:crossposts.instructions]]
</p>
<!-- IMPORT partials/category/filter-dropdown-right.tpl -->
</div>
<div class="card-footer text-end">
<i class="fa me-2" id="crosspost_topic_spinner"></i>
<button type="button" class="btn btn-sm btn-outline-secondary" id="crosspost_topic_cancel">[[global:buttons.close]]</button>
<button type="button" class="btn btn-sm btn-primary" id="crosspost_thread_commit" disabled>[[topic:confirm-crosspost]]</button>
</div>
</div>

View File

@@ -1,10 +0,0 @@
<div class="mb-3">
{{{ if crossposts.length }}}
<p>[[topic:crossposts.listing]]</p>
{{{ each crossposts }}}
{buildCategoryLabel(./category, "a", "border")}
{{{ end }}}
{{{ else }}}
<p>[[topic:crossposts.none]]</p>
{{{ end }}}
</div>

View File

@@ -16,14 +16,12 @@
</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="#">

View File

@@ -15,11 +15,9 @@
<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/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>

View File

@@ -76,6 +76,105 @@ describe('Actor asserton', () => {
assert.strictEqual(assertion.length, 1);
assert.strictEqual(assertion[0].cid, actor.id);
});
describe('remote user to remote category migration', () => {
it('should not migrate a user to a category if .assert is called', async () => {
// ... because the user isn't due for an update and so is filtered out during qualification
const { id } = helpers.mocks.person();
await activitypub.actors.assert([id]);
const { actor } = helpers.mocks.group({ id });
const assertion = await activitypub.actors.assertGroup([id]);
assert(assertion.length, 0);
const exists = await user.exists(id);
assert.strictEqual(exists, false);
});
it('should migrate a user to a category if on re-assertion it identifies as an as:Group', async () => {
// This is to handle previous behaviour that saved all as:Group actors as NodeBB users.
const { id } = helpers.mocks.person();
await activitypub.actors.assert([id]);
helpers.mocks.group({ id });
const assertion = await activitypub.actors.assertGroup([id]);
assert(assertion && Array.isArray(assertion) && assertion.length === 1);
const exists = await user.exists(id);
assert.strictEqual(exists, false);
});
it('should migrate any shares by that user, into topics in the category', async () => {
const { id } = helpers.mocks.person();
await activitypub.actors.assert([id]);
// Two shares
for (let x = 0; x < 2; x++) {
const { id: pid } = helpers.mocks.note();
// eslint-disable-next-line no-await-in-loop
const { tid } = await activitypub.notes.assert(0, pid, { skipChecks: 1 });
// eslint-disable-next-line no-await-in-loop
await db.sortedSetAdd(`uid:${id}:shares`, Date.now(), tid);
}
helpers.mocks.group({ id });
await activitypub.actors.assertGroup([id]);
const { topic_count, post_count } = await categories.getCategoryData(id);
assert.strictEqual(topic_count, 2);
assert.strictEqual(post_count, 2);
});
it('should not migrate shares by that user that already belong to a local category', async () => {
const { id } = helpers.mocks.person();
await activitypub.actors.assert([id]);
const { cid } = await categories.create({ name: utils.generateUUID() });
// Two shares, one moved to local cid
for (let x = 0; x < 2; x++) {
const { id: pid } = helpers.mocks.note();
// eslint-disable-next-line no-await-in-loop
const { tid } = await activitypub.notes.assert(0, pid, { skipChecks: 1 });
// eslint-disable-next-line no-await-in-loop
await db.sortedSetAdd(`uid:${id}:shares`, Date.now(), tid);
if (!x) {
// eslint-disable-next-line no-await-in-loop
await topics.tools.move(tid, {
cid,
uid: 'system',
});
}
}
helpers.mocks.group({ id });
await activitypub.actors.assertGroup([id]);
const { topic_count, post_count } = await categories.getCategoryData(id);
assert.strictEqual(topic_count, 1);
assert.strictEqual(post_count, 1);
});
it('should migrate any local followers into category watches', async () => {
const { id } = helpers.mocks.person();
await activitypub.actors.assert([id]);
const followerUid = await user.create({ username: utils.generateUUID() });
await Promise.all([
db.sortedSetAdd(`followingRemote:${followerUid}`, Date.now(), id),
db.sortedSetAdd(`followersRemote:${id}`, Date.now(), followerUid),
]);
helpers.mocks.group({ id });
await activitypub.actors.assertGroup([id]);
const states = await categories.getWatchState([id], followerUid);
assert.strictEqual(states[0], categories.watchStates.tracking);
});
});
});
describe('less happy paths', () => {

View File

@@ -164,7 +164,9 @@ describe('FEPs', () => {
});
pid = id;
({ activity } = await helpers.mocks.create(note));
console.log('before inbox create', activitypub._sent);
await activitypub.inbox.create({ body: activity });
console.log('after inbox create', activitypub._sent);
const activities = Array.from(activitypub._sent);
const test1 = activities.some((activity) => {

View File

@@ -153,32 +153,6 @@ Helpers.mocks.like = (override = {}) => {
return { activity };
};
Helpers.mocks.follow = (override = {}) => {
let actor = override.actor;
let object = override.object;
if (!actor) {
({ id: actor } = Helpers.mocks.person());
}
if (!object) {
({ id: object } = Helpers.mocks.person());
}
delete override.actor;
delete override.object;
const activity = {
'@context': 'https://www.w3.org/ns/activitystreams',
id: `${Helpers.mocks._baseUrl}/follow/${encodeURIComponent(object.id || object)}`,
type: 'Follow',
to: [activitypub._constants.publicAddress],
cc: [`${actor}/followers`],
actor,
object,
...override,
};
return { activity };
};
Helpers.mocks.announce = (override = {}) => {
let actor = override.actor;
let object = override.object;

View File

@@ -659,15 +659,10 @@ 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': {
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})`);
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})`);
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})`);

View File

@@ -1,559 +0,0 @@
'use strict';
const assert = require('assert');
const nconf = require('nconf');
const db = require('../mocks/databasemock');
const meta = require('../../src/meta');
const install = require('../../src/install');
const user = require('../../src/user');
const groups = require('../../src/groups');
const categories = require('../../src/categories');
const topics = require('../../src/topics');
const posts = require('../../src/posts');
const privileges = require('../../src/privileges');
const activitypub = require('../../src/activitypub');
const utils = require('../../src/utils');
describe('Crossposting (& related logic)', () => {
before(async () => {
meta.config.activitypubEnabled = 1;
await install.giveWorldPrivileges();
});
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 not let another user uncrosspost', async () => {
const uid2 = await user.create({ username: utils.generateUUID().slice(0, 8) });
assert.rejects(
topics.crossposts.remove(tid, cid2, uid2),
'[[error:invalid-data]]',
);
});
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]]',
);
});
});
describe('uncrosspost (as administrator)', () => {
let tid;
let cid1;
let cid2;
let uid;
let privUid;
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) });
privUid = await user.create({ username: utils.generateUUID().slice(0, 8) });
await groups.join('administrators', privUid);
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, privUid);
assert(Array.isArray(crossposts));
assert.strictEqual(crossposts.length, 0);
});
});
describe('uncrosspost (as global moderator)', () => {
let tid;
let cid1;
let cid2;
let uid;
let privUid;
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) });
privUid = await user.create({ username: utils.generateUUID().slice(0, 8) });
await groups.join('Global Moderators', privUid);
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, privUid);
assert(Array.isArray(crossposts));
assert.strictEqual(crossposts.length, 0);
});
});
describe('uncrosspost (as category moderator)', () => {
let tid;
let cid1;
let cid2;
let uid;
let privUid;
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) });
privUid = 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 fail to uncrosspost if not mod of passed-in category', async () => {
await privileges.categories.give(['moderate'], cid1, [privUid]);
assert.rejects(
topics.crossposts.remove(tid, cid2, privUid),
'[[error:invalid-data]]',
);
});
it('should successfully uncrosspost from a cid if proper mod', async () => {
await privileges.categories.give(['moderate'], cid2, [privUid]);
const crossposts = await topics.crossposts.remove(tid, cid2, privUid);
assert(Array.isArray(crossposts));
assert.strictEqual(crossposts.length, 0);
});
});
describe('Deletion', () => {
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);
await topics.delete(tid, uid);
});
it('should maintain crossposts when topic is deleted', async () => {
const crossposts = await topics.crossposts.get(tid);
assert(Array.isArray(crossposts));
assert.strictEqual(crossposts.length, 1);
});
});
describe('Purging', () => {
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);
await topics.purge(tid, uid);
});
it('should remove crossposts when topic is purged', async () => {
const crossposts = await topics.crossposts.get(tid);
assert(Array.isArray(crossposts));
assert.strictEqual(crossposts.length, 0);
});
});
describe('category sync; integration with', () => {
let cid;
let remoteCid;
let pid;
let post;
const helpers = require('../activitypub/helpers');
before(async () => {
({ cid } = await categories.create({ name: utils.generateUUID().slice(0, 8) }));
({ id: remoteCid } = helpers.mocks.group());
({ id: pid, note: post } = helpers.mocks.note({
audience: [remoteCid],
}));
// Mock a group follow/accept
const timestamp = Date.now();
console.log('saving', remoteCid);
await Promise.all([
db.sortedSetAdd(`cid:${cid}:following`, timestamp, remoteCid),
db.sortedSetAdd(`followersRemote:${remoteCid}`, timestamp, `cid|${cid}`),
]);
});
it('should automatically cross-post the topic when the remote category announces', async () => {
const { activity: body } = helpers.mocks.announce({
actor: remoteCid,
object: post,
});
await activitypub.inbox.announce({ body });
const tid = await posts.getPostField(pid, 'tid');
const crossposts = await topics.crossposts.get(tid);
assert.strictEqual(crossposts.length, 1);
assert.partialDeepStrictEqual(crossposts[0], {
uid: '0',
tid,
cid: String(cid),
});
});
});
describe('auto-categorization; integration with', () => {
let cid;
let remoteCid;
let pid;
let post;
const helpers = require('../activitypub/helpers');
before(async () => {
const preferredUsername = utils.generateUUID().slice(0, 8);
({ cid } = await categories.create({ name: utils.generateUUID().slice(0, 8) }));
({ id: remoteCid } = helpers.mocks.group({
preferredUsername,
}));
({ id: pid, note: post } = helpers.mocks.note({
audience: [remoteCid],
tag: [
{
type: 'Hashtag',
name: `#${preferredUsername}`,
},
],
}));
await activitypub.rules.add('hashtag', preferredUsername, cid);
});
it('note assertion should automatically cross-post', async () => {
await activitypub.notes.assert(0, pid, { skipChecks: true });
const tid = await posts.getPostField(pid, 'tid');
const crossposts = await topics.crossposts.get(tid);
assert.strictEqual(crossposts.length, 1);
assert.partialDeepStrictEqual(crossposts[0], {
uid: '0',
tid,
cid: String(cid),
});
});
});
describe('ActivityPub effects (or lack thereof)', () => {
describe('local canonical category', () => {
let tid;
let cid1;
let cid2;
let uid;
let pid;
const helpers = require('../activitypub/helpers');
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;
pid = topicData.mainPid,
// Add some remote followers
await Promise.all([cid1, cid2].map(async (cid) => {
const {activity} = helpers.mocks.follow({
object: {
id: `${nconf.get('url')}/category/${cid}`,
},
});
await activitypub.inbox.follow({
body: activity,
});
}));
activitypub._sent.clear();
});
afterEach(() => {
activitypub._sent.clear();
});
it('should not federate out any events on crosspost', async () => {
await topics.crossposts.add(tid, cid2, uid);
assert.strictEqual(activitypub._sent.size, 0);
});
it('should not federate out anything on uncrosspost', async () => {
await topics.crossposts.remove(tid, cid2, uid);
assert.strictEqual(activitypub._sent.size, 0);
});
it('should only federate an Announce on a remote reply from the canonical cid', async () => {
const { note: object } = helpers.mocks.note({
audience: `${nconf.get('url')}/category/${cid1}`,
inReplyTo: `${nconf.get('url')}/post/${pid}`,
});
const { activity } = helpers.mocks.create(object);
await activitypub.inbox.create({
body: activity,
});
assert.strictEqual(activitypub._sent.size, 1);
assert.partialDeepStrictEqual(Array.from(activitypub._sent).pop()[1], {
type: 'Announce',
actor: `${nconf.get('url')}/category/${cid1}`,
object: activity,
});
});
it('should only federate an Announce on a remote like from the canonical cid', async () => {
const { activity: body } = helpers.mocks.like({
object: {
id: `${nconf.get('url')}/post/${pid}`,
},
});
await activitypub.inbox.like({ body });
assert.strictEqual(activitypub._sent.size, 1);
assert.partialDeepStrictEqual(Array.from(activitypub._sent).pop()[1], {
type: 'Announce',
actor: `${nconf.get('url')}/category/${cid1}`,
object: body,
});
});
});
describe('remote canonical category', () => {
let tid;
let cid;
let remoteCid;
let uid;
let pid;
const helpers = require('../activitypub/helpers');
before(async () => {
({ id: remoteCid } = helpers.mocks.group());
await activitypub.actors.assertGroup(remoteCid);
({ cid } = await categories.create({ name: utils.generateUUID().slice(0, 8) }));
uid = await user.create({ username: utils.generateUUID().slice(0, 8) });
({ id: pid } = helpers.mocks.note());
await activitypub.notes.assert(0, pid, { skipChecks: 1, cid: remoteCid });
tid = await posts.getPostField(pid, 'tid');
await topics.crossposts.add(tid, cid, uid);
});
it('should properly address the remote category when federating out a local reply', async () => {
const postData = await topics.reply({
uid,
cid,
tid,
content: utils.generateUUID(),
});
const mocked = await activitypub.mocks.notes.public(postData);
assert(mocked.to.includes(remoteCid));
});
});
});
});

View File

@@ -1,109 +0,0 @@
'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]]'
);
});
});
});