Merge commit '9e1a0a13e14d0293e80ff0b9674fb46159a3b67d' into v4.x

This commit is contained in:
Misty Release Bot
2025-02-27 02:03:59 +00:00
34 changed files with 298 additions and 53 deletions

View File

@@ -1,3 +1,48 @@
#### v4.0.5 (2025-02-20)
##### Chores
* bump composer to 10.2.46 for #13132 (7520e4f6)
* up harmony (f82f00e5)
* up widgets (e23a14c1)
* up harmony (c0996a80)
* up dbsearch (d0a9ddea)
* up dbsearch (310fab65)
* add test helper to activitypub file (4bc0031f)
* incrementing version number - v4.0.4 (b1125cce)
* update changelog for v4.0.4 (d3b69a39)
* incrementing version number - v4.0.3 (2b65c735)
* incrementing version number - v4.0.2 (73fe5fcf)
* incrementing version number - v4.0.1 (a461b758)
* incrementing version number - v4.0.0 (c1eaee45)
##### New Features
* add upload button to quickreply (f67a0a12)
* remove activities older than a week (9997189a)
##### Bug Fixes
* typo (e63f1234)
* #13136, do not log 404s for AP requests (93f48409)
* #13129, serve category backgroundImage as actor `icon`, not `image` (b8200095)
* escape ip blacklist rules (625f4751)
* closes #13180, don't execute cron jobs if ap disabled (a410587c)
* #13172, Topics.addParentPosts not sending sourceContent in calling parsePosts (bb9687bd)
* #13179, fix context resolution failure bug with frequency (6245e33d)
* add back chronological sorting of asserted notes (de6e63bb)
* #13170, remove mime-type and regex test for "Emoji" attachment, wrap tag name in colons if not provided (92708d2f)
* closes #13176, check if uid is number when creating tokens (80cc1d34)
* notes.assertPrivate sanity checks (5e71d597)
* page index for single page, closes #13173 (b0e8058f)
* remove handle on category purge (4134a075)
##### Tests
* dont clear local when testing (669755d1)
* show objects on fail (f2824073)
* wait after post request (64318242)
#### v4.0.4 (2025-02-17)
##### Chores

View File

@@ -39,24 +39,24 @@
"@textcomplete/contenteditable": "0.1.13",
"@textcomplete/core": "0.1.13",
"@textcomplete/textarea": "0.1.13",
"ace-builds": "1.37.5",
"ace-builds": "1.38.0",
"archiver": "7.0.1",
"async": "3.2.6",
"autoprefixer": "10.4.20",
"bcryptjs": "2.4.3",
"benchpressjs": "2.5.1",
"benchpressjs": "2.5.3",
"body-parser": "1.20.3",
"bootbox": "6.0.0",
"bootstrap": "5.3.3",
"bootswatch": "5.3.3",
"chalk": "4.1.2",
"chart.js": "4.4.7",
"chart.js": "4.4.8",
"cli-graph": "3.2.2",
"clipboard": "2.0.11",
"colors": "1.4.0",
"commander": "12.1.0",
"compare-versions": "6.1.1",
"compression": "1.7.5",
"compression": "1.8.0",
"connect-flash": "0.1.1",
"connect-mongo": "5.1.0",
"connect-multiparty": "2.2.0",
@@ -68,7 +68,7 @@
"csrf-sync": "4.0.3",
"daemon": "1.1.0",
"diff": "7.0.0",
"esbuild": "0.24.2",
"esbuild": "0.25.0",
"express": "4.21.2",
"express-session": "1.18.1",
"express-useragent": "1.0.15",
@@ -93,45 +93,45 @@
"lru-cache": "10.4.3",
"mime": "3.0.0",
"mkdirp": "3.0.1",
"mongodb": "6.12.0",
"mongodb": "6.13.1",
"morgan": "1.10.0",
"mousetrap": "1.6.5",
"multiparty": "4.2.3",
"nconf": "0.12.1",
"nodebb-plugin-2factor": "7.5.9",
"nodebb-plugin-composer-default": "10.2.46",
"nodebb-plugin-dbsearch": "6.2.12",
"nodebb-plugin-composer-default": "10.2.47",
"nodebb-plugin-dbsearch": "6.2.13",
"nodebb-plugin-emoji": "6.0.2",
"nodebb-plugin-emoji-android": "4.1.1",
"nodebb-plugin-markdown": "13.1.0",
"nodebb-plugin-mentions": "4.6.10",
"nodebb-plugin-mentions": "4.7.0",
"nodebb-plugin-spam-be-gone": "2.3.1",
"nodebb-plugin-web-push": "0.7.2",
"nodebb-plugin-web-push": "0.7.3",
"nodebb-rewards-essentials": "1.0.1",
"nodebb-theme-harmony": "2.0.28",
"nodebb-theme-harmony": "2.0.37",
"nodebb-theme-lavender": "7.1.17",
"nodebb-theme-peace": "2.2.39",
"nodebb-theme-persona": "14.0.15",
"nodebb-widget-essentials": "7.0.34",
"nodemailer": "6.9.16",
"nodebb-widget-essentials": "7.0.35",
"nodemailer": "6.10.0",
"nprogress": "0.2.0",
"passport": "0.7.0",
"passport-http-bearer": "1.0.1",
"passport-local": "1.0.0",
"pg": "8.13.1",
"pg-cursor": "2.12.1",
"postcss": "8.5.1",
"pg": "8.13.3",
"pg-cursor": "2.12.3",
"postcss": "8.5.3",
"postcss-clean": "1.2.0",
"progress-webpack-plugin": "1.0.16",
"prompt": "1.3.0",
"ioredis": "5.4.2",
"ioredis": "5.5.0",
"rimraf": "5.0.10",
"rss": "1.2.2",
"rtlcss": "4.3.0",
"sanitize-html": "2.14.0",
"sass": "1.83.4",
"sass": "1.85.0",
"satori": "0.12.1",
"semver": "7.6.3",
"semver": "7.7.1",
"serve-favicon": "2.5.0",
"sharp": "0.32.6",
"sitemap": "8.0.0",
@@ -146,9 +146,9 @@
"timeago": "1.6.7",
"tinycon": "0.6.8",
"toobusy-js": "0.5.1",
"tough-cookie": "5.1.0",
"tough-cookie": "5.1.1",
"validator": "13.12.0",
"webpack": "5.97.1",
"webpack": "5.98.0",
"webpack-merge": "6.0.1",
"winston": "3.17.0",
"workerpool": "9.2.0",

View File

@@ -1,7 +1,8 @@
{
"category": "Category",
"subcategories": "Subcategories",
"uncategorized": "Uncategorized",
"uncategorized.description": "Topics that do not strictly fit in with any existing categories",
"new-topic-button": "New Topic",
"guest-login-post": "Log in to post",
"no-topics": "<strong>There are no topics in this category.</strong><br />Why don't you try posting one?",

View File

@@ -268,6 +268,7 @@
"invalid-plugin-id": "Invalid plugin ID",
"plugin-not-whitelisted": "Unable to install plugin &ndash; only plugins whitelisted by the NodeBB Package Manager can be installed via the ACP",
"plugin-installation-via-acp-disabled": "Plugin installation via ACP is disabled",
"plugins-set-in-configuration": "You are not allowed to change plugin state as they are defined at runtime (config.json, environmental variables or terminal arguments), please modify the configuration instead.",
"theme-not-set-in-configuration": "When defining active plugins in configuration, changing themes requires adding the new theme to the list of active plugins before updating it in the ACP",

View File

@@ -18,6 +18,9 @@ PostObject:
For posts received via ActivityPub, it is the url of the original piece of content.
content:
type: string
sourceContent:
type: string
nullable: true
uid:
type: number
description: A user identifier

View File

@@ -98,6 +98,8 @@ get:
type: boolean
jobsDisabled:
type: boolean
acpPluginInstallDisabled:
type: boolean
git:
type: object
properties:

View File

@@ -85,12 +85,24 @@ define('forum/topic/threadTools', [
topicContainer.on('click', '[component="topic/event/delete"]', function () {
const eventId = $(this).attr('data-topic-event-id');
const eventEl = $(this).parents('[component="topic/event"]');
const eventEl = $(this).parents('[data-topic-event-id]');
bootbox.confirm('[[topic:delete-event-confirm]]', (ok) => {
if (ok) {
api.del(`/topics/${tid}/events/${eventId}`, {})
.then(function () {
const itemsParent = eventEl.parents('[component="topic/event/items"]');
eventEl.remove();
if (itemsParent.length) {
const childrenCount = itemsParent.children().length;
const eventParent = itemsParent.parents('[component="topic/event"]');
if (!childrenCount) {
eventParent.remove();
} else {
eventParent
.find('[data-bs-toggle]')
.translateText(`[[topic:announcers-x, ${childrenCount}]]`);
}
}
})
.catch(alerts.error);
}

View File

@@ -1,6 +1,6 @@
'use strict';
define('logout', ['hooks'], function (hooks) {
define('logout', ['hooks', 'alerts'], function (hooks, alerts) {
return function logout(redirect) {
redirect = redirect === undefined ? true : redirect;
hooks.fire('action:app.logout');
@@ -23,6 +23,9 @@ define('logout', ['hooks'], function (hooks) {
}
}
},
error: function (jqXHR) {
alerts.error(String(jqXHR.responseText || '[[error:logout-error]]'));
},
});
};
});

View File

@@ -100,8 +100,16 @@ Actors.assert = async (ids, options = {}) => {
try {
activitypub.helpers.log(`[activitypub/actors] Processing ${id}`);
const actor = (typeof id === 'object' && id.hasOwnProperty('id')) ? id : await activitypub.get('uid', 0, id, { cache: process.env.CI === 'true' });
let typeOk = false;
if (Array.isArray(actor.type)) {
typeOk = actor.type.some(type => activitypub._constants.acceptableActorTypes.has(type));
} else {
typeOk = activitypub._constants.acceptableActorTypes.has(actor.type);
}
if (
!activitypub._constants.acceptableActorTypes.has(actor.type) ||
!typeOk ||
!activitypub._constants.requiredActorProps.every(prop => actor.hasOwnProperty(prop))
) {
return null;

View File

@@ -64,7 +64,7 @@ inbox.create = async (req) => {
const { object } = req.body;
// Alternative logic for non-public objects
const isPublic = [...object.to, ...object.cc].includes(activitypub._constants.publicAddress);
const isPublic = [...(object.to || []), ...(object.cc || [])].includes(activitypub._constants.publicAddress);
if (!isPublic) {
return await activitypub.notes.assertPrivate(object);
}

View File

@@ -29,12 +29,14 @@ const Mocks = module.exports;
* Done so the output HTML is stripped of all non-essential items; mainly classes from plugins..
*/
const sanitizeConfig = {
allowedTags: sanitize.defaults.allowedTags.concat(['img']),
allowedTags: sanitize.defaults.allowedTags.concat(['img', 'picture', 'source']),
allowedClasses: {
'*': [],
},
allowedAttributes: {
a: ['href', 'rel'],
source: ['type', 'src', 'srcset', 'sizes', 'media', 'height', 'width'],
img: ['alt', 'height', 'ismap', 'src', 'usemap', 'width', 'srcset'],
},
};

View File

@@ -234,7 +234,7 @@ Notes.assertPrivate = async (object) => {
}
const localUids = [];
const recipients = new Set([...object.to, ...object.cc]);
const recipients = new Set([...(object.to || []), ...(object.cc || [])]);
await Promise.all(Array.from(recipients).map(async (value) => {
const { type, id } = await activitypub.helpers.resolveLocalId(value);
if (type === 'user') {

View File

@@ -15,8 +15,8 @@ const intFields = [
const worldCategory = {
cid: -1,
name: 'Uncategorized',
description: 'Topics that do not strictly fit in with any existing categories',
name: '[[category:uncategorized]]',
description: '[[category:uncategorized.description]]',
icon: 'fa-globe',
imageClass: 'cover',
bgColor: '#eee',

View File

@@ -66,7 +66,6 @@ controller.list = async function (req, res) {
targetUid: targetUid,
};
const data = await categories.getCategoryById(cidQuery);
data.name = '[[world:name]]';
delete data.children;
const tids = await getTids(cidQuery);

View File

@@ -88,6 +88,7 @@ async function getNodeInfo() {
isPrimary: nconf.get('isPrimary'),
runJobs: nconf.get('runJobs'),
jobsDisabled: nconf.get('jobsDisabled'),
acpPluginInstallDisabled: nconf.get('acpPluginInstallDisabled'),
},
};

View File

@@ -199,7 +199,7 @@ async function loadUserInfo(callerUid, uids) {
const confirmObj = confirmObjs[index];
user['email:expired'] = !confirmObj.expires || Date.now() >= confirmObj.expires;
user['email:pending'] = confirmObj.expires && Date.now() < confirmObj.expires;
user.emailToConfirm = confirmObj.email;
user.emailToConfirm = validator.escape(String(confirmObj.email));
}
}
});

View File

@@ -459,7 +459,7 @@ authenticationController.localLogin = async function (req, username, password, n
}
};
authenticationController.logout = async function (req, res, next) {
authenticationController.logout = async function (req, res) {
if (!req.loggedIn || !req.sessionID) {
res.clearCookie(nconf.get('sessionKey'), meta.configs.cookie.get());
return res.status(200).send('not-logged-in');
@@ -475,21 +475,22 @@ authenticationController.logout = async function (req, res, next) {
await user.setUserField(uid, 'lastonline', Date.now() - (meta.config.onlineCutoff * 60000));
await db.sortedSetAdd('users:online', Date.now() - (meta.config.onlineCutoff * 60000), uid);
await plugins.hooks.fire('static:user.loggedOut', { req: req, res: res, uid: uid, sessionID: sessionID });
await plugins.hooks.fire('static:user.loggedOut', { req, res, uid, sessionID });
// Force session check for all connected socket.io clients with the same session id
sockets.in(`sess_${sessionID}`).emit('checkSession', 0);
const payload = {
next: `${nconf.get('relative_path')}/`,
};
plugins.hooks.fire('filter:user.logout', payload);
await plugins.hooks.fire('filter:user.logout', payload);
if (req.body.noscript === 'true') {
return res.redirect(payload.next);
}
res.status(200).send(payload);
} catch (err) {
next(err);
winston.error(`${req.method} ${req.originalUrl}\n${err.stack}`);
res.status(500).send(err.message);
}
};

View File

@@ -12,6 +12,7 @@ const fs = require('fs');
const _ = require('lodash');
const jwt = require('jsonwebtoken');
const db = require('./database');
const User = require('./user');
const Plugins = require('./plugins');
const meta = require('./meta');
@@ -223,6 +224,13 @@ Emailer.send = async (template, uid, params) => {
// 'welcome' and 'verify-email' explicitly used passed-in email address
if (['welcome', 'verify-email'].includes(template)) {
userData.email = params.email;
} else if (meta.config.includeUnverifiedEmails && !userData.email) {
// get unconfirmed email to use
const code = await db.get(`confirm:byUid:${uid}`);
const confirmObj = code ? await db.getObject(`confirm:${code}`) : null;
if (confirmObj && confirmObj.email) {
userData.email = String(confirmObj.email);
}
}
({ template, userData, params } = await Plugins.hooks.fire('filter:email.prepare', { template, uid, userData, params }));

View File

@@ -22,7 +22,7 @@ module.exports = function (Posts) {
options.escape = options.hasOwnProperty('escape') ? options.escape : false;
options.extraFields = options.hasOwnProperty('extraFields') ? options.extraFields : [];
const fields = ['pid', 'tid', 'toPid', 'url', 'content', 'uid', 'timestamp', 'deleted', 'upvotes', 'downvotes', 'replies', 'handle'].concat(options.extraFields);
const fields = ['pid', 'tid', 'toPid', 'url', 'content', 'sourceContent', 'uid', 'timestamp', 'deleted', 'upvotes', 'downvotes', 'replies', 'handle'].concat(options.extraFields);
let posts = await Posts.getPostsFields(pids, fields);
posts = posts.filter(Boolean);
@@ -74,7 +74,7 @@ module.exports = function (Posts) {
async function parsePosts(posts, options) {
return await Promise.all(posts.map(async (post) => {
if (!post.content) {
if (!post.content && !post.sourceContent) {
return post;
}
if (options.parse) {

View File

@@ -58,6 +58,7 @@ function loadConfig(configFile) {
isCluster: false,
isPrimary: true,
jobsDisabled: false,
acpPluginInstallDisabled: false,
fontawesome: {
pro: false,
styles: '*',
@@ -65,7 +66,7 @@ function loadConfig(configFile) {
});
// Explicitly cast as Bool, loader.js passes in isCluster as string 'true'/'false'
const castAsBool = ['isCluster', 'isPrimary', 'jobsDisabled'];
const castAsBool = ['isCluster', 'isPrimary', 'jobsDisabled', 'acpPluginInstallDisabled'];
nconf.stores.env.readOnly = false;
castAsBool.forEach((prop) => {
const value = nconf.get(prop);

View File

@@ -39,8 +39,8 @@ module.exports = function (app, middleware, controllers) {
const oneMonthAgo = addMonths(new Date(), -1);
const sixMonthsAgo = addMonths(new Date(), -6);
const [{ postCount, userCount }, activeMonth, activeHalfyear] = await Promise.all([
db.getObjectFields('global', ['postCount', 'userCount']),
const [{ postCount, topicCount, userCount }, activeMonth, activeHalfyear] = await Promise.all([
db.getObjectFields('global', ['postCount', 'topicCount', 'userCount']),
db.sortedSetCount('users:online', oneMonthAgo.getTime(), '+inf'),
db.sortedSetCount('users:online', sixMonthsAgo.getTime(), '+inf'),
]);
@@ -64,7 +64,8 @@ module.exports = function (app, middleware, controllers) {
activeMonth: activeMonth,
activeHalfyear: activeHalfyear,
},
localPosts: postCount,
localPosts: topicCount,
localComments: postCount - topicCount,
},
openRegistrations: meta.config.registrationType === 'normal',
metadata: {

View File

@@ -22,6 +22,10 @@ Plugins.toggleActive = async function (socket, plugin_id) {
};
Plugins.toggleInstall = async function (socket, data) {
const isInstalled = await plugins.isInstalled(data.id);
if (nconf.get('acpPluginInstallDisabled') && !isInstalled) {
throw new Error('[[error:plugin-installation-via-acp-disabled]]');
}
postsCache.reset();
await plugins.checkWhitelist(data.id, data.version);
const pluginData = await plugins.toggleInstall(data.id, data.version);

View File

@@ -17,6 +17,10 @@ const batch = require('../batch');
const SocketHelpers = module.exports;
SocketHelpers.notifyNew = async function (uid, type, result) {
const post = result.posts[0];
if (post && post.topic && parseInt(post.topic.cid, 10) === -1) {
return;
}
let uids = await user.getUidsFromSet('users:online', 0, -1);
uids = uids.filter(toUid => parseInt(toUid, 10) !== uid);
await batch.processArray(uids, async (uids) => {

View File

@@ -107,7 +107,7 @@ function renderUser(event) {
if (!event.user || event.user.system) {
return '[[global:system-user]]';
}
return `${helpers.buildAvatar(event.user, '16px', true)} <a href="${relative_path}/user/${event.user.userslug}">${event.user.username}</a>`;
return `${helpers.buildAvatar(event.user, '16px', true)} <a href="${relative_path}/user/${event.user.userslug}">${event.user.displayname}</a>`;
}
function renderTimeago(event) {

View File

@@ -198,6 +198,7 @@ Topics.getTopicWithPosts = async function (topicData, set, uid, start, stop, rev
);
p.eventStart = undefined;
p.eventEnd = undefined;
p.events = mergeConsecutiveShareEvents(p.events);
});
topicData.category = category;
@@ -230,6 +231,23 @@ Topics.getTopicWithPosts = async function (topicData, set, uid, start, stop, rev
return result.topic;
};
function mergeConsecutiveShareEvents(arr) {
return arr.reduce((acc, curr) => {
const last = acc[acc.length - 1];
if (last && last.type === curr.type && last.type === 'share') {
if (!last.items) {
last.items = [{ ...last }];
['user', 'text', 'timestamp', 'timestampISO'].forEach(field => delete last[field]);
}
last.items.push(curr);
} else {
acc.push(curr);
}
return acc;
}, []);
}
async function getDeleter(topicData) {
if (!parseInt(topicData.deleterUid, 10)) {
return null;

View File

@@ -3,7 +3,6 @@
'use strict';
const db = require('../../database');
const user = require('../../user');
const batch = require('../../batch');
module.exports = {
@@ -18,7 +17,7 @@ module.exports = {
}
await batch.processSortedSet(`users:joindate`, async (uids) => {
const userSettings = await user.getMultipleUserSettings(uids);
const userSettings = await db.getObjects(uids.map(uid => `user:${uid}:settings`));
const change = userSettings.filter(s => s && s.categoryWatchState === 'watching');
await db.setObjectBulk(
change.map(s => [`user:${s.uid}:settings`, { categoryWatchState: 'tracking' }])

View File

@@ -36,8 +36,10 @@ UserEmail.remove = async function (uid, sessionId) {
email: '',
'email:confirmed': 0,
}),
db.sortedSetRemove('email:uid', email.toLowerCase()),
db.sortedSetRemove('email:sorted', `${email.toLowerCase()}:${uid}`),
db.sortedSetRemoveBulk([
['email:uid', email.toLowerCase()],
['email:sorted', `${email.toLowerCase()}:${uid}`],
]),
user.email.expireValidation(uid),
sessionId ? user.auth.revokeAllSessions(uid, sessionId) : Promise.resolve(),
events.log({
@@ -53,7 +55,7 @@ UserEmail.getEmailForValidation = async (uid) => {
let email = '';
// check email from confirmObj
const code = await db.get(`confirm:byUid:${uid}`);
const confirmObj = await db.getObject(`confirm:${code}`);
const confirmObj = code ? await db.getObject(`confirm:${code}`) : null;
if (confirmObj && confirmObj.email && parseInt(uid, 10) === parseInt(confirmObj.uid, 10)) {
email = confirmObj.email;
}

View File

@@ -282,6 +282,9 @@ module.exports = function (User) {
if (oldEmail === newEmail) {
return;
}
if (await User.email.isValidationPending(uid, newEmail)) {
return;
}
// 👉 Looking for email change logic? src/user/email.js (UserEmail.confirmByUid)
if (newEmail) {

View File

@@ -4,7 +4,7 @@
<div><i class="fa fa-sm fa-reply opacity-50"></i></div>
<div class="d-flex flex-nowrap gap-1 align-items-center">
<a href="{config.relative_path}/user/{messages.parent.user.userslug}" class="text-decoration-none lh-1">{buildAvatar(messages.parent.user, "14px", true, "not-responsive align-middle")}</a>
<a class="chat-user fw-semibold" href="{config.relative_path}/user/{messages.parent.user.userslug}">{messages.parent.user.displayname}</a>
<a class="chat-user fw-semibold text-truncate" style="max-width: 150px;" href="{config.relative_path}/user/{messages.parent.user.userslug}">{messages.parent.user.displayname}</a>
</div>
<span class="chat-timestamp text-muted timeago text-nowrap hidden" title="{messages.parent.timestampISO}"></span>
</div>

View File

@@ -1,9 +1,9 @@
<div component="post/parent" data-parent-pid="{./parent.pid}" data-uid="{./parent.uid}" class="btn btn-ghost btn-sm d-flex gap-2 text-start flex-row mb-2" style="font-size: 13px;">
<div class="d-flex gap-2 text-nowrap">
<div><i class="fa fa-sm fa-reply opacity-50"></i></div>
<div><i class="fa fa-fw fa-reply opacity-50"></i></div>
<div class="d-flex flex-nowrap gap-1 align-items-center">
<a href="{config.relative_path}/user/{./parent.user.userslug}" class="text-decoration-none lh-1">{buildAvatar(./parent.user, "14px", true, "not-responsive align-middle")}</a>
<a class="fw-semibold" href="{config.relative_path}/user/{./parent.user.userslug}">{./parent.user.displayname}</a>
<a href="{config.relative_path}/user/{./parent.user.userslug}" class="text-decoration-none lh-1">{buildAvatar(./parent.user, "16px", true, "not-responsive align-middle")}</a>
<a class="fw-semibold text-truncate" style="max-width: 150px;" href="{config.relative_path}/user/{./parent.user.userslug}">{./parent.user.displayname}</a>
</div>
<a href="{config.relative_path}/post/{./parent.pid}" class="text-muted timeago text-nowrap hidden" title="{./parent.timestampISO}"></a>

View File

@@ -350,7 +350,7 @@ describe('ActivityPub integration', () => {
});
});
describe.only('Category Actor endpoint', () => {
describe('Category Actor endpoint', () => {
let cid;
let slug;
let description;

View File

@@ -0,0 +1,60 @@
'use strict';
const utils = require('../../src/utils');
const activitypub = require('../../src/activitypub');
const Helpers = module.exports;
Helpers.mocks = {};
Helpers.mocks.note = (override = {}) => {
const baseUrl = 'https://example.org';
const uuid = utils.generateUUID();
const id = `${baseUrl}/object/${uuid}`;
const note = {
'@context': 'https://www.w3.org/ns/activitystreams',
id,
url: id,
type: 'Note',
to: ['https://www.w3.org/ns/activitystreams#Public'],
cc: ['https://example.org/user/foobar/followers'],
inReplyTo: null,
attributedTo: 'https://example.org/user/foobar',
name: utils.generateUUID(),
content: `<p>${utils.generateUUID()}</p>`,
published: new Date().toISOString(),
...override,
};
// If any values contain the hardcoded string "remove", remove that prop
Object.entries(note).forEach(([key, value]) => {
if (value === 'remove') {
delete note[key];
}
});
activitypub._cache.set(`0;${id}`, note);
return { id, note };
};
Helpers.mocks.create = (object) => {
// object is optional, will generate a public note if undefined
const baseUrl = 'https://example.org';
const uuid = utils.generateUUID();
const id = `${baseUrl}/activity/${uuid}`;
object = object || Helpers.mocks.note().note;
const activity = {
'@context': 'https://www.w3.org/ns/activitystreams',
id,
type: 'Create',
to: ['https://www.w3.org/ns/activitystreams#Public'],
cc: ['https://example.org/user/foobar/followers'],
actor: 'https://example.org/user/foobar',
object,
};
activitypub._cache.set(`0;${id}`, activity);
return { id, activity };
};

View File

@@ -1,15 +1,81 @@
'use strict';
const assert = require('assert');
const nconf = require('nconf');
const db = require('../../src/database');
const meta = require('../../src/meta');
const install = require('../../src/install');
const user = require('../../src/user');
const categories = require('../../src/categories');
const posts = require('../../src/posts');
const topics = require('../../src/topics');
const activitypub = require('../../src/activitypub');
const utils = require('../../src/utils');
const helpers = require('./helpers');
describe('Notes', () => {
describe('Assertion', () => {
before(async () => {
meta.config.activitypubEnabled = 1;
await install.giveWorldPrivileges();
});
describe('Public objects', () => {
it('should pull a remote root-level object by its id and create a new topic', async () => {
const { id } = helpers.mocks.note();
const { tid, count } = await activitypub.notes.assert(0, id, { skipChecks: true });
assert.strictEqual(count, 1);
const exists = await topics.exists(tid);
assert(exists);
});
it('should assert if the cc property is missing', async () => {
const { id } = helpers.mocks.note({ cc: 'remove' });
const { tid, count } = await activitypub.notes.assert(0, id, { skipChecks: true });
assert.strictEqual(count, 1);
const exists = await topics.exists(tid);
assert(exists);
});
});
describe('Private objects', () => {
let recipientUid;
before(async () => {
recipientUid = await user.create({ username: utils.generateUUID().slice(0, 8) });
});
it('should NOT create a new topic or post when asserting a private note', async () => {
const { id, note } = helpers.mocks.note({
to: [`${nconf.get('url')}/uid/${recipientUid}`],
cc: [],
});
const { activity } = helpers.mocks.create(note);
const { roomId } = await activitypub.inbox.create({ body: activity });
assert(roomId);
assert(utils.isNumber(roomId));
const exists = await posts.exists(id);
assert(!exists);
});
it('should still assert if the cc property is missing', async () => {
const { id, note } = helpers.mocks.note({
to: [`${nconf.get('url')}/uid/${recipientUid}`],
cc: 'remove',
});
const { activity } = helpers.mocks.create(note);
const { roomId } = await activitypub.inbox.create({ body: activity });
assert(roomId);
assert(utils.isNumber(roomId));
});
});
});
describe('Inbox Synchronization', () => {
let cid;
let uid;

View File

@@ -144,6 +144,7 @@ before(async function () {
nconf.set('version', packageInfo.version);
nconf.set('runJobs', false);
nconf.set('jobsDisabled', false);
nconf.set('acpPluginInstallDisabled', false);
await db.init();