mirror of
https://github.com/NodeBB/NodeBB.git
synced 2026-02-26 08:31:22 +01:00
Merge commit '9e1a0a13e14d0293e80ff0b9674fb46159a3b67d' into v4.x
This commit is contained in:
45
CHANGELOG.md
45
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -268,6 +268,7 @@
|
||||
|
||||
"invalid-plugin-id": "Invalid plugin ID",
|
||||
"plugin-not-whitelisted": "Unable to install plugin – 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",
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -98,6 +98,8 @@ get:
|
||||
type: boolean
|
||||
jobsDisabled:
|
||||
type: boolean
|
||||
acpPluginInstallDisabled:
|
||||
type: boolean
|
||||
git:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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]]'));
|
||||
},
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -88,6 +88,7 @@ async function getNodeInfo() {
|
||||
isPrimary: nconf.get('isPrimary'),
|
||||
runJobs: nconf.get('runJobs'),
|
||||
jobsDisabled: nconf.get('jobsDisabled'),
|
||||
acpPluginInstallDisabled: nconf.get('acpPluginInstallDisabled'),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' }])
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -350,7 +350,7 @@ describe('ActivityPub integration', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe.only('Category Actor endpoint', () => {
|
||||
describe('Category Actor endpoint', () => {
|
||||
let cid;
|
||||
let slug;
|
||||
let description;
|
||||
|
||||
60
test/activitypub/helpers.js
Normal file
60
test/activitypub/helpers.js
Normal 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 };
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user