Merge commit '23298060b1c72a240818a07d3b62dd3cd5ae1e08' into v3.x

This commit is contained in:
Misty Release Bot
2025-01-08 17:51:23 +00:00
47 changed files with 709 additions and 169 deletions

View File

@@ -1,3 +1,74 @@
#### v3.12.1 (2024-12-20)
##### Chores
* up harmony (18904bbb)
* up persona (b4ec3a6a)
* incrementing version number - v3.12.0 (052c195e)
* update changelog for v3.12.0 (5395062d)
* incrementing version number - v3.11.1 (0c0dd480)
* incrementing version number - v3.11.0 (acf27e85)
* incrementing version number - v3.10.3 (57d54224)
* incrementing version number - v3.10.2 (2f15f464)
* incrementing version number - v3.10.1 (cca3a644)
* incrementing version number - v3.10.0 (b60a9b4e)
* incrementing version number - v3.9.1 (f120c91c)
* incrementing version number - v3.9.0 (4880f32d)
* incrementing version number - v3.8.4 (4833f9a6)
* incrementing version number - v3.8.3 (97ce2c44)
* incrementing version number - v3.8.2 (72d91251)
* incrementing version number - v3.8.1 (527326f7)
* incrementing version number - v3.8.0 (e228a6eb)
* incrementing version number - v3.7.5 (6882894d)
* incrementing version number - v3.7.4 (6678744c)
* incrementing version number - v3.7.3 (2d62b6f6)
* incrementing version number - v3.7.2 (cc257e7e)
* incrementing version number - v3.7.1 (712365a5)
* incrementing version number - v3.7.0 (9a6153d7)
* incrementing version number - v3.6.7 (86a17e38)
* incrementing version number - v3.6.6 (6604bf37)
* incrementing version number - v3.6.5 (6c653625)
* incrementing version number - v3.6.4 (83d131b4)
* incrementing version number - v3.6.3 (fc7d2bfd)
* incrementing version number - v3.6.2 (0f577a57)
* incrementing version number - v3.6.1 (f1a69468)
* incrementing version number - v3.6.0 (4cdf85f8)
* incrementing version number - v3.5.3 (ed0e8783)
* incrementing version number - v3.5.2 (52fbb2da)
* incrementing version number - v3.5.1 (4c543488)
* incrementing version number - v3.5.0 (d06fb4f0)
* incrementing version number - v3.4.3 (5c984250)
* incrementing version number - v3.4.2 (3f0dac38)
* incrementing version number - v3.4.1 (01e69574)
* incrementing version number - v3.4.0 (fd9247c5)
* incrementing version number - v3.3.9 (5805e770)
* incrementing version number - v3.3.8 (a5603565)
* incrementing version number - v3.3.7 (b26f1744)
* incrementing version number - v3.3.6 (7fb38792)
* incrementing version number - v3.3.4 (a67f84ea)
* incrementing version number - v3.3.3 (f94d239b)
* incrementing version number - v3.3.2 (ec9dac97)
* incrementing version number - v3.3.1 (151cc68f)
* incrementing version number - v3.3.0 (fc1ad70f)
* incrementing version number - v3.2.3 (b06d3e63)
* incrementing version number - v3.2.2 (758ecfcd)
* incrementing version number - v3.2.1 (20145074)
* incrementing version number - v3.2.0 (9ecac38e)
* incrementing version number - v3.1.7 (0b4e81ab)
* incrementing version number - v3.1.6 (b3a3b130)
* incrementing version number - v3.1.5 (ec19343a)
* incrementing version number - v3.1.4 (2452783c)
* incrementing version number - v3.1.3 (3b4e9d3f)
* incrementing version number - v3.1.2 (40fa3489)
* incrementing version number - v3.1.1 (40250733)
* incrementing version number - v3.1.0 (0cb386bd)
* incrementing version number - v3.0.1 (26f6ea49)
* incrementing version number - v3.0.0 (224e08cd)
##### Bug Fixes
* check install.values, it can be undefined (9bb8002a)
#### v3.12.0 (2024-12-18)
##### Chores

View File

@@ -107,11 +107,14 @@
"flags:actionOnReject": "rescind",
"notificationType_upvote": "notification",
"notificationType_new-topic": "notification",
"notificationType_new-topic-with-tag": "notification",
"notificationType_new-topic-in-category": "notification",
"notificationType_new-reply": "notification",
"notificationType_post-edit": "notification",
"notificationType_follow": "notification",
"notificationType_new-chat": "notification",
"notificationType_new-group-chat": "notification",
"notificationType_new-public-chat": "none",
"notificationType_group-invite": "notification",
"notificationType_group-leave": "notification",
"notificationType_group-request-membership": "notification",

View File

@@ -98,20 +98,20 @@
"multiparty": "4.2.3",
"nconf": "0.12.1",
"nodebb-plugin-2factor": "7.5.7",
"nodebb-plugin-composer-default": "10.2.43",
"nodebb-plugin-composer-default": "10.2.44",
"nodebb-plugin-dbsearch": "6.2.5",
"nodebb-plugin-emoji": "5.1.15",
"nodebb-plugin-emoji-android": "4.0.0",
"nodebb-plugin-markdown": "12.2.8",
"nodebb-plugin-markdown": "12.2.9",
"nodebb-plugin-mentions": "4.4.5",
"nodebb-plugin-ntfy": "1.7.7",
"nodebb-plugin-spam-be-gone": "2.3.0",
"nodebb-rewards-essentials": "1.0.0",
"nodebb-theme-harmony": "1.2.92",
"nodebb-theme-harmony": "1.2.95",
"nodebb-theme-lavender": "7.1.17",
"nodebb-theme-peace": "2.2.32",
"nodebb-theme-persona": "13.3.61",
"nodebb-widget-essentials": "7.0.31",
"nodebb-theme-peace": "2.2.33",
"nodebb-theme-persona": "13.3.63",
"nodebb-widget-essentials": "7.0.32",
"nodemailer": "6.9.16",
"nprogress": "0.2.0",
"passport": "0.7.0",

View File

@@ -2,6 +2,7 @@
"post-sort-option": "Post sort option, %1",
"topic-sort-option": "Topic sort option, %1",
"user-avatar-for": "User avatar for %1",
"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"

View File

@@ -122,8 +122,6 @@ get:
type: array
items:
type: string
resizeImageWidth:
type: number
cookies:
type: object
properties:

View File

@@ -122,8 +122,6 @@ get:
type: array
items:
type: string
resizeImageWidth:
type: number
cookies:
type: object
properties:

View File

@@ -117,16 +117,21 @@ blockquote {
.hover-visible {
visibility: hidden;
}
.hover-opacity-75 {
opacity: 0;
&:focus { opacity: 0.75 }
}
.hover-opacity-100 {
opacity: 0;
&:focus {opacity: 1; }
}
&:hover {
.hover-d-block {
display: block!important;
}
.hover-d-flex {
display: flex!important;
}
.hover-visible {
visibility: visible;
}
.hover-d-block { display: block!important; }
.hover-d-flex { display: flex!important; }
.hover-visible { visibility: visible; }
.hover-opacity-100 { opacity: 1; }
.hover-opacity-75 { opacity: 0.75; }
}
}

View File

@@ -220,7 +220,7 @@ if (document.readyState === 'loading') {
if (!isTouchDevice) {
els = els || $('body');
els.tooltip({
selector: '.avatar.avatar-tooltip',
selector: '.avatar-tooltip',
placement: placement || 'top',
container: '#content',
animation: false,

View File

@@ -6,14 +6,19 @@ define('forum/header/notifications', function () {
notifications.prepareDOM = function () {
const notifTrigger = $('[component="notifications"] [data-bs-toggle="dropdown"]');
notifTrigger.on('show.bs.dropdown', (ev) => {
requireAndCall('loadNotifications', $(ev.target).parent().find('[component="notifications/list"]'));
notifTrigger.on('show.bs.dropdown', async (ev) => {
const notifications = await app.require('notifications');
const triggerEl = $(ev.target);
notifications.loadNotifications(triggerEl, triggerEl.parent().find('[component="notifications/list"]'));
});
notifTrigger.each((index, el) => {
const dropdownEl = $(el).parent().find('.dropdown-menu');
const triggerEl = $(el);
const dropdownEl = triggerEl.parent().find('.dropdown-menu');
if (dropdownEl.hasClass('show')) {
requireAndCall('loadNotifications', dropdownEl.find('[component="notifications/list"]'));
app.require('notifications').then((notifications) => {
notifications.loadNotifications(triggerEl, dropdownEl.find('[component="notifications/list"]'));
});
}
});
@@ -24,18 +29,14 @@ define('forum/header/notifications', function () {
socket.on('event:notifications.updateCount', onUpdateCount);
};
function onNewNotification(data) {
requireAndCall('onNewNotification', data);
async function onNewNotification(data) {
const notifications = await app.require('notifications');
notifications.onNewNotification(data);
}
function onUpdateCount(data) {
requireAndCall('updateNotifCount', data);
}
function requireAndCall(method, param) {
require(['notifications'], function (notifications) {
notifications[method](param);
});
async function onUpdateCount(data) {
const notifications = await app.require('notifications');
notifications.updateNotifCount(data);
}
return notifications;

View File

@@ -29,7 +29,10 @@ define('forum/topic/fork', [
$('body').append(forkModal);
categorySelector.init(forkModal.find('[component="category-selector"]'), {
const dropdownEl = forkModal.find('[component="category-selector"]');
dropdownEl.addClass('dropup');
categorySelector.init(dropdownEl, {
onSelect: function (category) {
selectedCategory = category;
},

View File

@@ -19,7 +19,7 @@ define('forum/topic/images', [], function () {
}
if (!imageEl.parent().is('a')) {
if (utils.isRelativeUrl(src) && suffixRegex.test(src) && imageEl.get(0).naturalWidth >= config.resizeImageWidth) {
if (utils.isRelativeUrl(src) && suffixRegex.test(src)) {
src = src.replace(suffixRegex, '$1');
}
const alt = imageEl.attr('alt') || '';

View File

@@ -28,8 +28,10 @@ define('forum/topic/move', [
if (Move.moveAll || (Move.tids && Move.tids.length > 1)) {
modal.find('.card-header').translateText('[[topic:move-topics]]');
}
const dropdownEl = modal.find('[component="category-selector"]');
dropdownEl.addClass('dropup');
categorySelector.init(modal.find('[component="category-selector"]'), {
categorySelector.init(dropdownEl, {
onSelect: onCategorySelected,
privilege: 'moderate',
});

View File

@@ -214,9 +214,9 @@ module.exports = function (utils, Benchpress, relative_path) {
function renderTopicImage(topicObj) {
if (topicObj.thumb) {
return '<img src="' + topicObj.thumb + '" class="img-circle user-img" title="' + topicObj.user.username + '" />';
return '<img src="' + topicObj.thumb + '" class="img-circle user-img" title="' + topicObj.user.displayname + '" />';
}
return '<img component="user/picture" data-uid="' + topicObj.user.uid + '" src="' + topicObj.user.picture + '" class="user-img" title="' + topicObj.user.username + '" />';
return '<img component="user/picture" data-uid="' + topicObj.user.uid + '" src="' + topicObj.user.picture + '" class="user-img" title="' + topicObj.user.displayname + '" />';
}
function renderDigestAvatar(block) {
@@ -300,7 +300,7 @@ module.exports = function (utils, Benchpress, relative_path) {
}
classNames = classNames || '';
const attributes = new Map([
['title', userObj.username],
['title', userObj.displayname],
['data-uid', userObj.uid],
['class', `avatar ${classNames}${rounded ? ' avatar-rounded' : ''}`],
]);
@@ -313,7 +313,7 @@ module.exports = function (utils, Benchpress, relative_path) {
let output = '';
if (userObj.picture) {
output += `<img${attr2String(attributes)} alt="${userObj.username}" loading="lazy" component="${component || 'avatar/picture'}" src="${userObj.picture}" style="${styles.join(' ')}" onError="this.remove()" itemprop="image" />`;
output += `<img${attr2String(attributes)} alt="${userObj.displayname}" loading="lazy" component="${component || 'avatar/picture'}" src="${userObj.picture}" style="${styles.join(' ')}" onError="this.remove()" itemprop="image" />`;
}
output += `<span${attr2String(attributes)} component="${component || 'avatar/icon'}" style="${styles.join(' ')} background-color: ${userObj['icon:bgColor']}">${userObj['icon:text']}</span>`;
return output;
@@ -383,7 +383,7 @@ module.exports = function (utils, Benchpress, relative_path) {
</li>`;
});
return html;
return html.join('');
}
function register() {

View File

@@ -28,7 +28,7 @@ define('notifications', [
});
hooks.on('filter:notifications.load', _addTimeagoString);
Notifications.loadNotifications = function (notifList, callback) {
Notifications.loadNotifications = function (triggerEl, notifList, callback) {
callback = callback || function () {};
socket.emit('notifications.get', null, function (err, data) {
if (err) {
@@ -47,7 +47,7 @@ define('notifications', [
if (scrollToPostIndexIfOnPage(notifEl)) {
ev.stopPropagation();
ev.preventDefault();
components.get('notifications/list').dropdown('toggle');
triggerEl.dropdown('toggle');
}
const unread = notifEl.hasClass('unread');

View File

@@ -28,9 +28,9 @@ define('search', [
const toggleVisibility = searchFields.hasClass('hidden');
if (toggleVisibility) {
searchInput.off('blur').on('blur', function dismissSearch() {
searchFields.off('focusout').on('focusout', function dismissSearch() {
setTimeout(function () {
if (!searchInput.is(':focus')) {
if (!searchFields.find(':focus').length) {
searchFields.addClass('hidden');
searchButton.removeClass('hidden');
}
@@ -177,30 +177,33 @@ define('search', [
doSearch();
}, 500));
let mousedownOnResults = false;
quickSearchResults.on('mousedown', '.quick-search-results > *', function () {
$(window).one('mouseup', function () {
quickSearchResults.addClass('hidden');
});
mousedownOnResults = true;
});
inputEl.on('blur', function () {
const inputParent = inputEl.parent();
const resultParent = quickSearchResults.parent();
inputParent.on('focusout', hideResults);
resultParent.on('focusout', hideResults);
function hideResults() {
setTimeout(function () {
if (!inputEl.is(':focus') && !mousedownOnResults && !quickSearchResults.hasClass('hidden')) {
if (!inputParent.find(':focus').length && !resultParent.find(':focus').length && !quickSearchResults.hasClass('hidden')) {
quickSearchResults.addClass('hidden');
}
}, 200);
});
}
let ajaxified = false;
hooks.on('action:ajaxify.end', function () {
if (!ajaxify.isCold()) {
ajaxified = true;
}
quickSearchResults.addClass('hidden');
});
inputEl.on('focus', function () {
mousedownOnResults = false;
const query = inputEl.val();
oldValue = query;
if (query && quickSearchResults.find('#quick-search-results').children().length) {

View File

@@ -79,6 +79,39 @@
imageDimensions = getBackgroundImageDimensions($el);
}
$(window).on('keydown.dbg', (e) => {
var pos = $el.css('background-position').match(/(-?\d+).*?\s(-?\d+)/) || [];
var xPos = parseInt(pos[1]) || 0;
var yPos = parseInt(pos[2]) || 0;
// We must convert percentage back to pixels
if (options.units == 'percent') {
xPos = Math.round(xPos / -200 * imageDimensions.width);
yPos = Math.round(yPos / -200 * imageDimensions.height);
}
var x = 0, y = 0;
if (e.which === 37) { // left
x = -5
} else if (e.which === 39) { // right
x = 5
} else if (e.which === 38) { // up
y = -5
} else if (e.which === 40) { // down
y = +5
}
if (options.units === 'percent') {
xPos = options.axis === 'y' ? xPos : limit(-imageDimensions.width/2, 0, xPos+x, options.bound);
yPos = options.axis === 'x' ? yPos : limit(-imageDimensions.height/2, 0, yPos+y, options.bound);
// Convert pixels to percentage
$el.css('background-position', xPos / imageDimensions.width * -200 + '% ' + yPos / imageDimensions.height * -200 + '%');
} else {
xPos = options.axis === 'y' ? xPos : limit($el.innerWidth()-imageDimensions.width, 0, xPos+x, options.bound);
yPos = options.axis === 'x' ? yPos : limit($el.innerHeight()-imageDimensions.height, 0, yPos+y, options.bound);
}
return [37, 38, 39, 40].includes(e.which) ? false : undefined;
});
$el.on('mousedown.dbg touchstart.dbg', function(e) {
if (e.target !== $el[0]) {
return;
@@ -145,7 +178,7 @@
Plugin.prototype.disable = function() {
var $el = $(this.element);
$el.off('mousedown.dbg touchstart.dbg');
$window.off('mousemove.dbg touchmove.dbg mouseup.dbg touchend.dbg mouseleave.dbg');
$window.off('mousemove.dbg touchmove.dbg mouseup.dbg touchend.dbg mouseleave.dbg keydown.dbg');
}
$.fn.backgroundDraggable = function(options) {

View File

@@ -370,10 +370,10 @@ postsAPI.getUpvoters = async function (caller, data) {
upvotedUids = upvotedUids.slice(0, cutoff - 1);
}
const usernames = await user.getUsernamesByUids(upvotedUids);
const users = await user.getUsersFields(upvotedUids, ['username']);
return {
otherCount,
usernames,
usernames: users.map(user => user.displayname),
cutoff,
};
};

View File

@@ -87,7 +87,8 @@ program
.option('--log-level <level>', 'Default logging level to use', 'info')
.option('--config <value>', 'Specify a config file', 'config.json')
.option('-d, --dev', 'Development mode, including verbose logging', false)
.option('-l, --log', 'Log subprocess output to console', false);
.option('-l, --log', 'Log subprocess output to console', false)
.option('-y, --unattended', 'Answer yes to any prompts, like plugin upgrades', false);
// provide a yargs object ourselves
// otherwise yargs will consume `--help` or `help`
@@ -294,6 +295,7 @@ program
].join('\n')}`);
})
.action((scripts, options) => {
options.unattended = program.opts().unattended;
if (program.opts().dev) {
process.env.NODE_ENV = 'development';
global.env = 'development';
@@ -308,7 +310,8 @@ program
.alias('upgradePlugins')
.description('Upgrade plugins')
.action(() => {
require('./upgrade-plugins').upgradePlugins((err) => {
const { unattended } = program.opts();
require('./upgrade-plugins').upgradePlugins(unattended, (err) => {
if (err) {
throw err;
}

View File

@@ -120,7 +120,7 @@ async function checkPlugins() {
return upgradable;
}
async function upgradePlugins() {
async function upgradePlugins(unattended = false) {
try {
const found = await checkPlugins();
if (found && found.length) {
@@ -132,16 +132,18 @@ async function upgradePlugins() {
console.log(chalk.green('\nAll packages up-to-date!'));
return;
}
let result = { upgrade: 'y' };
if (!unattended) {
prompt.message = '';
prompt.delimiter = '';
prompt.message = '';
prompt.delimiter = '';
prompt.start();
const result = await prompt.get({
name: 'upgrade',
description: '\nProceed with upgrade (y|n)?',
type: 'string',
});
prompt.start();
result = await prompt.get({
name: 'upgrade',
description: '\nProceed with upgrade (y|n)?',
type: 'string',
});
}
if (['y', 'Y', 'yes', 'YES'].includes(result.upgrade)) {
console.log('\nUpgrading packages...');

View File

@@ -24,9 +24,9 @@ const steps = {
},
plugins: {
message: 'Checking installed plugins for updates...',
handler: async function () {
handler: async function (options) {
await require('../database').init();
await upgradePlugins();
await upgradePlugins(options.unattended);
},
},
schema: {
@@ -45,14 +45,14 @@ const steps = {
},
};
async function runSteps(tasks) {
async function runSteps(tasks, options) {
try {
for (let i = 0; i < tasks.length; i++) {
const step = steps[tasks[i]];
if (step && step.message && step.handler) {
process.stdout.write(`\n${chalk.bold(`${i + 1}. `)}${chalk.yellow(step.message)}`);
/* eslint-disable-next-line */
await step.handler();
await step.handler(options);
}
}
const message = 'NodeBB Upgrade Complete!';
@@ -95,7 +95,7 @@ async function runUpgrade(upgrades, options) {
options.plugins || options.schema || options.build) {
tasks = tasks.filter(key => options[key]);
}
await runSteps(tasks);
await runSteps(tasks, options);
return;
}

View File

@@ -160,7 +160,7 @@ helpers.getCustomUserFields = async function (callerUID, userData) {
if (f.type === 'input-link' && userValue) {
f.linkValue = validator.escape(String(userValue.replace('http://', '').replace('https://', '')));
}
f['select-options'] = f['select-options'].split('\n').filter(Boolean).map(
f['select-options'] = (f['select-options'] || '').split('\n').filter(Boolean).map(
opt => ({
value: opt,
selected: Array.isArray(userValue) ?

View File

@@ -113,8 +113,14 @@ const doUnsubscribe = async (payload) => {
user.updateDigestSetting(payload.uid, 'off'),
]);
} else if (payload.template === 'notification') {
const currentToNewSetting = {
notificationemail: 'notification',
email: 'none',
};
const current = await db.getObjectField(`user:${payload.uid}:settings`, `notificationType_${payload.type}`);
await user.setSetting(payload.uid, `notificationType_${payload.type}`, (current === 'notificationemail' ? 'notification' : 'none'));
if (currentToNewSetting.hasOwnProperty(current)) {
await user.setSetting(payload.uid, `notificationType_${payload.type}`, currentToNewSetting[current]);
}
}
return true;
};

View File

@@ -78,7 +78,6 @@ apiController.loadConfig = async function (req) {
enablePostHistory: meta.config.enablePostHistory === 1,
timeagoCutoff: meta.config.timeagoCutoff !== '' ? Math.max(0, parseInt(meta.config.timeagoCutoff, 10)) : meta.config.timeagoCutoff,
timeagoCodes: languages.timeagoCodes,
resizeImageWidth: meta.config.resizeImageWidth,
cookies: {
enabled: meta.config.cookieConsentEnabled === 1,
message: translator.escape(validator.escape(meta.config.cookieConsentMessage || '[[global:cookies.message]]')).replace(/\\/g, '\\\\'),
@@ -91,7 +90,7 @@ apiController.loadConfig = async function (req) {
},
emailPrompt: meta.config.emailPrompt,
useragent: {
isSafari: req.useragent.isSafari,
isSafari: req.useragent && req.useragent.isSafari,
},
fontawesome: {
pro: fontawesome_pro,

View File

@@ -49,7 +49,10 @@ async function registerAndLoginUser(req, res, userData) {
const uid = await user.create(userData);
if (res.locals.processLogin) {
await authenticationController.doLogin(req, uid);
const hasLoginPrivilege = await privileges.global.can('local:login', uid);
if (hasLoginPrivilege) {
await authenticationController.doLogin(req, uid);
}
}
// Distinguish registrations through invites from direct ones

View File

@@ -6,7 +6,7 @@ const validator = require('validator');
const meta = require('../meta');
const user = require('../user');
const plugins = require('../plugins');
const privileges = require('../privileges');
const privilegesHelpers = require('../privileges/helpers');
const helpers = require('./helpers');
const Controllers = module.exports;
@@ -124,7 +124,8 @@ Controllers.login = async function (req, res) {
data.title = '[[pages:login]]';
data.allowPasswordReset = !meta.config['password:disableEdit'];
const hasLoginPrivilege = await privileges.global.canGroup('local:login', 'registered-users');
const loginPrivileges = await privilegesHelpers.getGroupPrivileges(0, ['groups:local:login']);
const hasLoginPrivilege = !!loginPrivileges.find(privilege => privilege.privileges['groups:local:login']);
data.allowLocalLogin = hasLoginPrivilege || parseInt(req.query.local, 10) === 1;
if (!data.allowLocalLogin && !data.allowRegistration && data.alternate_logins && data.authentication.length === 1) {

View File

@@ -174,11 +174,12 @@ module.exports = function (module) {
}
const data = {};
fields.forEach((field) => {
fields = fields.map((field) => {
field = helpers.fieldToString(field);
if (field) {
data[field] = 1;
}
return field;
});
const item = await module.client.collection('objects').findOne({ _key: key }, { projection: data });
@@ -194,14 +195,13 @@ module.exports = function (module) {
if (!key || (Array.isArray(key) && !key.length) || !Array.isArray(fields) || !fields.length) {
return;
}
fields = fields.filter(Boolean);
fields = fields.map(helpers.fieldToString).filter(Boolean);
if (!fields.length) {
return;
}
const data = {};
fields.forEach((field) => {
field = helpers.fieldToString(field);
data[field] = '';
});
if (Array.isArray(key)) {

View File

@@ -23,7 +23,9 @@ helpers.fieldToString = function (field) {
field = field.toString();
}
// if there is a '.' in the field name it inserts subdocument in mongo, replace '.'s with \uff0E
return field.replace(/\./g, '\uff0E');
// replace $ with \uff04 so we can use $ in document fields
return field.replace(/\./g, '\uff0E')
.replace(/\$/g, '\uFF04');
};
helpers.serializeData = function (data) {

View File

@@ -172,8 +172,11 @@ module.exports = function (module) {
if (key === undefined || key === null || field === undefined || field === null) {
return;
}
await module.client.hdel(key, field);
cache.del(key);
field = field.toString();
if (field) {
await module.client.hdel(key, field);
cache.del(key);
}
};
module.deleteObjectFields = async function (key, fields) {

View File

@@ -354,8 +354,11 @@ Emailer.sendViaFallback = async (data) => {
data.text = data.plaintext;
delete data.plaintext;
// NodeMailer uses a combined "from"
data.from = `${data.from_name}<${data.from}>`;
// use an address object https://nodemailer.com/message/addresses/
data.from = {
name: data.from_name,
address: data.from,
};
delete data.from_name;
await Emailer.fallbackTransport.sendMail(data);
};

View File

@@ -103,7 +103,7 @@ image.size = async function (path) {
};
image.stripEXIF = async function (path) {
if (!meta.config.stripEXIFData || path.endsWith('.svg')) {
if (!meta.config.stripEXIFData || path.endsWith('.gif') || path.endsWith('.svg')) {
return;
}
try {

View File

@@ -164,7 +164,10 @@ actions.buildCSS = async function buildCSS(data) {
loadPaths: data.paths,
};
if (data.minify) {
opts.silenceDeprecations = ['mixed-decls', 'color-functions'];
opts.silenceDeprecations = [
'legacy-js-api', 'mixed-decls', 'color-functions',
'global-builtin', 'import',
];
}
const scssOutput = await sass.compileStringAsync(data.source, opts);
css = scssOutput.css.toString();

View File

@@ -254,7 +254,7 @@ module.exports = function (middleware) {
if (res.locals.isAPI) {
req.params.userslug = lowercaseSlug;
} else {
const newPath = req.path.replace(new RegExp(`/${req.params.userslug}`), () => `/${lowercaseSlug}`);
const newPath = req.path.replace(`/${req.params.userslug}`, () => `/${lowercaseSlug}`);
return res.redirect(`${nconf.get('relative_path')}${newPath}`);
}
}

View File

@@ -66,8 +66,8 @@ _mounts.post = (app, name, middleware, controllers) => {
middleware.registrationComplete,
middleware.pluginHooks,
];
app.get(`/${name}/:pid`, middleware.busyCheck, middlewares, controllers.posts.redirectToPost);
app.get(`/api/${name}/:pid`, middlewares, controllers.posts.redirectToPost);
app.get(`/${name}/:pid`, middleware.busyCheck, middlewares, helpers.tryRoute(controllers.posts.redirectToPost));
app.get(`/api/${name}/:pid`, middlewares, helpers.tryRoute(controllers.posts.redirectToPost));
};
_mounts.tags = (app, name, middleware, controllers) => {

View File

@@ -16,7 +16,7 @@ const tx = require('../translator');
module.exports = function (User) {
User.updateProfile = async function (uid, data, extraFields) {
let fields = [
'username', 'email', 'fullname', 'website', 'location',
'username', 'email', 'fullname',
'groupTitle', 'birthday', 'signature', 'aboutme',
...await db.getSortedSetRange('user-custom-fields', 0, -1),
];

View File

@@ -140,12 +140,12 @@
<div class="card">
<div class="card-header d-flex justify-content-between">[[admin/dashboard:popular-searches]] <a href="{config.relative_path}/admin/dashboard/searches" class="text-xs">[[admin/dashboard:view-all]]</a></div>
<div class="card-body">
<table class="table table-sm text-sm search-list">
<table class="table table-sm text-sm search-list w-100">
<tbody>
{{{ each popularSearches }}}
<tr>
<td>{popularSearches.value}</td>
<td class="text-end" style="width: 1px;">{formattedNumber(popularSearches.score)}</td>
<td class="w-100 text-truncate" style="max-width:1px;">{popularSearches.value}</td>
<td class="w-0 text-end text-nowrap">{formattedNumber(popularSearches.score)}</td>
</tr>
{{{ end }}}
</tbody>

View File

@@ -17,7 +17,7 @@
<button id="clear-search-history" class="btn btn-sm btn-light"><i class="fa fa-trash text-danger"></i> [[admin/dashboard:clear-search-history]]</button>
</div>
<table class="table table-sm text-sm search-list">
<table class="table table-sm text-sm search-list w-100">
<thead>
<th>[[admin/dashboard:search-term]]</th>
<th class="text-end">[[admin/dashboard:search-count]]</th>
@@ -30,8 +30,8 @@
{{{ end }}}
{{{ each searches }}}
<tr>
<td>{searches.value}</td>
<td class="text-end" style="width: 1px;">{formattedNumber(searches.score)}</td>
<td class="w-100 text-truncate" style="max-width:1px;">{searches.value}</td>
<td class="w-0 text-end text-nowrap">{formattedNumber(searches.score)}</td>
</tr>
{{{ end }}}
</tbody>

View File

@@ -39,7 +39,7 @@
</div>
<div class="btn-group">
<button class="btn btn-primary btn-sm dropdown-toggle" id="action-dropdown" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" type="button" disabled="disabled">[[admin/manage/users:edit]] <span class="caret"></span></button>
<ul class="dropdown-menu dropdown-menu-end p-1 text-sm" role="menu">
<ul class="dropdown-menu dropdown-menu-end p-1 text-sm overflow-auto" role="menu" style="max-height:75vh;">
<li><h6 class="dropdown-header">[[admin/manage/users:email]]</h6></li>
<li><a href="#" class="dropdown-item rounded-1 change-email" role="menuitem"><i class="text-secondary fa fa-fw fa-envelope text-start"></i> [[admin/manage/users:change-email]]</a></li>

View File

@@ -1,6 +1,4 @@
<div class="tool-modal d-flex">
<div class="card shadow">
<h5 class="card-header">[[topic:thread-tools.merge-topics]]</h5>
<div class="card-body">
@@ -13,7 +11,7 @@
<span class="input-group-text"><i class="fa fa-search"></i></span>
</div>
<div class="quick-search-container dropdown-menu d-block p-2 hidden">
<div class="quick-search-container dropdown-menu d-block p-2 hidden w-100">
<div class="text-center loading-indicator"><i class="fa fa-spinner fa-spin"></i></div>
<div class="quick-search-results-container"></div>
</div>

View File

@@ -3,7 +3,7 @@
{{{ end }}}
<div component="chat/recent/room" data-roomid="{./roomId}" data-full="1" class="rounded-1 {{{ if ./unread }}}unread{{{ end }}}">
<div class="d-flex gap-1 justify-content-between">
<div class="chat-room-btn position-relative d-flex flex-grow-1 gap-2 justify-content-start align-items-start btn btn-ghost btn-sm ff-sans text-start">
<a href="#" class="chat-room-btn position-relative d-flex flex-grow-1 gap-2 justify-content-start align-items-start btn btn-ghost btn-sm ff-sans text-start">
<div class="main-avatar">
{{{ if ./users.length }}}
{{{ if ./groupChat}}}
@@ -33,7 +33,7 @@
</div>
<!-- IMPORT partials/chats/room-teaser.tpl -->
</div>
</div>
</a>
<div>
<button class="mark-read btn btn-ghost btn-sm d-flex align-items-center justify-content-center flex-grow-0 flex-shrink-0 p-1" style="width: 1.5rem; height: 1.5rem;">
<i class="unread fa fa-2xs fa-circle text-primary {{{ if !./unread }}}hidden{{{ end }}}" aria-label="[[unread:mark-as-read]]"></i>

View File

@@ -31,7 +31,6 @@ describe('Controllers', () => {
let fooUid;
let adminUid;
let category;
let testRoutes = [];
before(async () => {
category = await categories.create({
@@ -56,48 +55,6 @@ describe('Controllers', () => {
tid = result.topicData.tid;
pid = result.postData.pid;
testRoutes = [
{ it: 'should load /reset without code', url: '/reset' },
{ it: 'should load /reset with invalid code', url: '/reset/123123' },
{ it: 'should load /login', url: '/login' },
{ it: 'should load /register', url: '/register' },
{ it: 'should load /robots.txt', url: '/robots.txt' },
{ it: 'should load /manifest.webmanifest', url: '/manifest.webmanifest' },
{ it: 'should load /outgoing?url=<url>', url: '/outgoing?url=http://youtube.com' },
{ it: 'should 404 on /outgoing with no url', url: '/outgoing', status: 404 },
{ it: 'should 404 on /outgoing with javascript: protocol', url: '/outgoing?url=javascript:alert(1);', status: 404 },
{ it: 'should 404 on /outgoing with invalid url', url: '/outgoing?url=derp', status: 404 },
{ it: 'should load /sping', url: '/sping', body: 'healthy' },
{ it: 'should load /ping', url: '/ping', body: '200' },
{ it: 'should handle 404', url: '/arouteinthevoid', status: 404 },
{ it: 'should load topic rss feed', url: `/topic/${tid}.rss` },
{ it: 'should load category rss feed', url: `/category/${cid}.rss` },
{ it: 'should load topics rss feed', url: `/topics.rss` },
{ it: 'should load recent rss feed', url: `/recent.rss` },
{ it: 'should load top rss feed', url: `/top.rss` },
{ it: 'should load popular rss feed', url: `/popular.rss` },
{ it: 'should load popular rss feed with term', url: `/popular/day.rss` },
{ it: 'should load recent posts rss feed', url: `/recentposts.rss` },
{ it: 'should load category recent posts rss feed', url: `/category/${cid}/recentposts.rss` },
{ it: 'should load user topics rss feed', url: `/user/foo/topics.rss` },
{ it: 'should load tag rss feed', url: `/tags/nodebb.rss` },
{ it: 'should load client.css', url: `/assets/client.css` },
{ it: 'should load admin.css', url: `/assets/admin.css` },
{ it: 'should load sitemap.xml', url: `/sitemap.xml` },
{ it: 'should load sitemap/pages.xml', url: `/sitemap/pages.xml` },
{ it: 'should load sitemap/categories.xml', url: `/sitemap/categories.xml` },
{ it: 'should load sitemap/topics.1.xml', url: `/sitemap/topics.1.xml` },
{ it: 'should load theme screenshot', url: `/css/previews/nodebb-theme-harmony` },
{ it: 'should load users page', url: `/users` },
{ it: 'should load users page section', url: `/users?section=online` },
{ it: 'should load groups page', url: `/groups` },
{ it: 'should get recent posts', url: `/api/recent/posts/month` },
{ it: 'should get post data', url: `/api/v3/posts/${pid}` },
{ it: 'should get topic data', url: `/api/v3/topics/${tid}` },
{ it: 'should get category data', url: `/api/v3/categories/${cid}` },
{ it: 'should return osd data', url: `/osd.xml` },
];
});
it('should load /config with csrf_token', async () => {
@@ -222,6 +179,48 @@ describe('Controllers', () => {
describe('routes that should 200/404 etc.', () => {
const baseUrl = nconf.get('url');
const testRoutes = [
{ it: 'should load /reset without code', url: '/reset' },
{ it: 'should load /reset with invalid code', url: '/reset/123123' },
{ it: 'should load /login', url: '/login' },
{ it: 'should load /register', url: '/register' },
{ it: 'should load /robots.txt', url: '/robots.txt' },
{ it: 'should load /manifest.webmanifest', url: '/manifest.webmanifest' },
{ it: 'should load /outgoing?url=<url>', url: '/outgoing?url=http://youtube.com' },
{ it: 'should 404 on /outgoing with no url', url: '/outgoing', status: 404 },
{ it: 'should 404 on /outgoing with javascript: protocol', url: '/outgoing?url=javascript:alert(1);', status: 404 },
{ it: 'should 404 on /outgoing with invalid url', url: '/outgoing?url=derp', status: 404 },
{ it: 'should load /sping', url: '/sping', body: 'healthy' },
{ it: 'should load /ping', url: '/ping', body: '200' },
{ it: 'should handle 404', url: '/arouteinthevoid', status: 404 },
{ it: 'should load topic rss feed', url: `/topic/1.rss` },
{ it: 'should load category rss feed', url: `/category/1.rss` },
{ it: 'should load topics rss feed', url: `/topics.rss` },
{ it: 'should load recent rss feed', url: `/recent.rss` },
{ it: 'should load top rss feed', url: `/top.rss` },
{ it: 'should load popular rss feed', url: `/popular.rss` },
{ it: 'should load popular rss feed with term', url: `/popular/day.rss` },
{ it: 'should load recent posts rss feed', url: `/recentposts.rss` },
{ it: 'should load category recent posts rss feed', url: `/category/1/recentposts.rss` },
{ it: 'should load user topics rss feed', url: `/user/foo/topics.rss` },
{ it: 'should load tag rss feed', url: `/tags/nodebb.rss` },
{ it: 'should load client.css', url: `/assets/client.css` },
{ it: 'should load admin.css', url: `/assets/admin.css` },
{ it: 'should load sitemap.xml', url: `/sitemap.xml` },
{ it: 'should load sitemap/pages.xml', url: `/sitemap/pages.xml` },
{ it: 'should load sitemap/categories.xml', url: `/sitemap/categories.xml` },
{ it: 'should load sitemap/topics.1.xml', url: `/sitemap/topics.1.xml` },
{ it: 'should load theme screenshot', url: `/css/previews/nodebb-theme-harmony` },
{ it: 'should load users page', url: `/users` },
{ it: 'should load users page section', url: `/users?section=online` },
{ it: 'should load groups page', url: `/groups` },
{ it: 'should get recent posts', url: `/api/recent/posts/month` },
{ it: 'should get post data', url: `/api/v3/posts/1` },
{ it: 'should get topic data', url: `/api/v3/topics/1` },
{ it: 'should get category data', url: `/api/v3/categories/1` },
{ it: 'should return osd data', url: `/osd.xml` },
{ it: 'should load service worker', url: '/service-worker.js' },
];
testRoutes.forEach((route) => {
it(route.it, async () => {
const { response, body } = await request.get(`${baseUrl}/${route.url}`);

View File

@@ -155,18 +155,21 @@ describe('Hash methods', () => {
});
});
it('should work for field names with "." in them when they are cached', (done) => {
db.setObjectField('dotObject3', 'my.dot.field', 'foo2', (err) => {
assert.ifError(err);
db.getObject('dotObject3', (err, data) => {
assert.ifError(err);
db.getObjectField('dotObject3', 'my.dot.field', (err, value) => {
assert.ifError(err);
assert.equal(value, 'foo2');
done();
});
});
});
it('should work for field names with "." in them when they are cached', async () => {
await db.setObjectField('dotObject3', 'my.dot.field', 'foo2');
const data = await db.getObject('dotObject3');
assert.strictEqual(data['my.dot.field'], 'foo2');
const value = await db.getObjectField('dotObject3', 'my.dot.field');
assert.equal(value, 'foo2');
});
it('should work for fields that start with $', async () => {
await db.setObjectField('dollarsign', '$someField', 'foo');
assert.strictEqual(await db.getObjectField('dollarsign', '$someField'), 'foo');
assert.strictEqual(await db.isObjectField('dollarsign', '$someField'), true);
assert.strictEqual(await db.isObjectField('dollarsign', '$doesntexist'), false);
await db.deleteObjectField('dollarsign', '$someField');
assert.strictEqual(await db.isObjectField('dollarsign', '$someField'), false);
});
});
@@ -521,6 +524,7 @@ describe('Hash methods', () => {
it('should not error if fields is empty array', async () => {
await db.deleteObjectFields('someKey', []);
await db.deleteObjectField('someKey', []);
});
it('should not error if key is undefined', (done) => {

View File

@@ -86,7 +86,7 @@ describe('Post\'s', () => {
assert.deepStrictEqual(await db.sortedSetScores(`tid:${postResult.topicData.tid}:posters`, [oldUid, newUid]), [2, null]);
await posts.changeOwner([pid1, pid2], newUid);
await socketPosts.changeOwner({ uid: globalModUid }, { pids: [pid1, pid2], toUid: newUid });
assert.deepStrictEqual(await db.sortedSetScores(`tid:${postResult.topicData.tid}:posters`, [oldUid, newUid]), [null, 2]);
@@ -1072,6 +1072,65 @@ describe('Post\'s', () => {
});
});
describe('post editors', () => {
it('should fail with invalid data', async () => {
await assert.rejects(
socketPosts.saveEditors({ uid: 0 }, {
pid: 1,
uids: [1],
}),
{ message: '[[error:no-privileges]]' },
);
await assert.rejects(
socketPosts.saveEditors({ uid: 0 }, null),
{ message: '[[error:invalid-data]]' },
);
await assert.rejects(
socketPosts.saveEditors({ uid: 0 }, {
pid: null,
uids: [1],
}),
{ message: '[[error:invalid-data]]' },
);
await assert.rejects(
socketPosts.saveEditors({ uid: 0 }, {
pid: 1,
uids: null,
}),
{ message: '[[error:invalid-data]]' },
);
await assert.rejects(
socketPosts.getEditors({ uid: 0 }, null),
{ message: '[[error:invalid-data]]' },
);
await assert.rejects(
socketPosts.saveEditors({ uid: 0 }, { pid: null }),
{ message: '[[error:invalid-data]]' },
);
});
it('should add another user to post editors', async () => {
const ownerUid = await user.create({ username: 'owner user' });
const editorUid = await user.create({ username: 'editor user' });
const topic = await topics.post({
uid: ownerUid,
cid,
title: 'just a topic for multi editor testing',
content: `Some text here for the OP`,
});
const { pid } = topic.postData;
await socketPosts.saveEditors({ uid: ownerUid }, {
pid: pid,
uids: [editorUid],
});
const userData = await socketPosts.getEditors({ uid: ownerUid }, { pid: pid });
assert.strictEqual(userData[0].username, 'editor user');
});
});
describe('Topic Backlinks', () => {
let tid1;
before(async () => {

View File

@@ -199,6 +199,26 @@ describe('socket.io', () => {
assert(Array.isArray(users[0].groups));
});
it('should error with invalid data set user reputation', async () => {
await assert.rejects(
socketAdmin.user.setReputation({ uid: adminUid }, null),
{ message: '[[error:invalid-data]]' }
);
await assert.rejects(
socketAdmin.user.setReputation({ uid: adminUid }, {}),
{ message: '[[error:invalid-data]]' }
);
await assert.rejects(
socketAdmin.user.setReputation({ uid: adminUid }, { uids: [], value: null }),
{ message: '[[error:invalid-data]]' }
);
});
it('should set user reputation', async () => {
await socketAdmin.user.setReputation({ uid: adminUid }, { uids: [adminUid], value: 10 });
assert.strictEqual(10, await db.sortedSetScore('users:reputation', adminUid));
});
it('should reset lockouts', (done) => {
socketAdmin.user.resetLockouts({ uid: adminUid }, [regularUid], (err) => {
assert.ifError(err);

View File

@@ -52,6 +52,17 @@ describe('helpers', () => {
done();
});
it('should return true if route is visible', (done) => {
const flag = helpers.displayMenuItem({
navigation: [{ route: '/recent' }],
user: {
privileges: {},
},
}, 0);
assert(flag);
done();
});
it('should stringify object', (done) => {
const str = helpers.stringify({ a: 'herp < derp > and & quote "' });
assert.equal(str, '{&quot;a&quot;:&quot;herp &lt; derp &gt; and &amp; quote \\&quot;&quot;}');
@@ -64,7 +75,57 @@ describe('helpers', () => {
done();
});
it('should build category icon', (done) => {
assert.strictEqual(
helpers.buildCategoryIcon({
bgColor: '#ff0000',
color: '#00ff00',
backgroundImage: '/assets/uploads/image.png',
imageClass: 'auto',
}, 16, 'rounded-circle'),
'<span class="icon d-inline-flex justify-content-center align-items-center align-middle rounded-circle" style="background-color: #ff0000; border-color: #ff0000!important; color: #00ff00; background-image: url(/assets/uploads/image.png); background-size: auto; width:16; height: 16; font-size: 8px;"></span>'
);
assert.strictEqual(
helpers.buildCategoryIcon({
bgColor: '#ff0000',
color: '#00ff00',
backgroundImage: '/assets/uploads/image.png',
imageClass: 'auto',
icon: 'fa-book',
}, 16, 'rounded-circle'),
'<span class="icon d-inline-flex justify-content-center align-items-center align-middle rounded-circle" style="background-color: #ff0000; border-color: #ff0000!important; color: #00ff00; background-image: url(/assets/uploads/image.png); background-size: auto; width:16; height: 16; font-size: 8px;"><i class="fa fa-fw fa-book"></i></span>'
);
done();
});
it('should build category label', (done) => {
assert.strictEqual(
helpers.buildCategoryLabel({
bgColor: '#ff0000',
color: '#00ff00',
backgroundImage: '/assets/uploads/image.png',
imageClass: 'auto',
name: 'Category 1',
}, 'a', ''),
`<a href="${nconf.get('relative_path')}/category/undefined" class="badge px-1 text-truncate text-decoration-none " style="color: #00ff00;background-color: #ff0000;border-color: #ff0000!important; max-width: 70vw;">\n\t\t\t\n\t\t\tCategory 1\n\t\t</a>`
);
assert.strictEqual(
helpers.buildCategoryLabel({
bgColor: '#ff0000',
color: '#00ff00',
backgroundImage: '/assets/uploads/image.png',
imageClass: 'auto',
name: 'Category 1',
icon: 'fa-book',
}, 'span', 'rounded-1'),
`<span class="badge px-1 text-truncate text-decoration-none rounded-1" style="color: #00ff00;background-color: #ff0000;border-color: #ff0000!important; max-width: 70vw;">\n\t\t\t<i class="fa fa-fw fa-book"></i>\n\t\t\tCategory 1\n\t\t</span>`,
);
done();
});
it('should return empty string if category is falsy', (done) => {
assert.equal(helpers.buildCategoryIcon(null), '');
assert.equal(helpers.buildCategoryLabel(null), '');
assert.equal(helpers.generateCategoryBackground(null), '');
done();
});
@@ -169,16 +230,16 @@ describe('helpers', () => {
});
it('should render thumb as topic image', (done) => {
const topicObj = { thumb: '/uploads/1.png', user: { username: 'baris' } };
const topicObj = { thumb: '/uploads/1.png', user: { username: 'baris', displayname: 'Baris Soner Usakli' } };
const html = helpers.renderTopicImage(topicObj);
assert.equal(html, `<img src="${topicObj.thumb}" class="img-circle user-img" title="${topicObj.user.username}" />`);
assert.equal(html, `<img src="${topicObj.thumb}" class="img-circle user-img" title="${topicObj.user.displayname}" />`);
done();
});
it('should render user picture as topic image', (done) => {
const topicObj = { thumb: '', user: { uid: 1, username: 'baris', picture: '/uploads/2.png' } };
const topicObj = { thumb: '', user: { uid: 1, username: 'baris', displayname: 'Baris Soner Usakli', picture: '/uploads/2.png' } };
const html = helpers.renderTopicImage(topicObj);
assert.equal(html, `<img component="user/picture" data-uid="${topicObj.user.uid}" src="${topicObj.user.picture}" class="user-img" title="${topicObj.user.username}" />`);
assert.equal(html, `<img component="user/picture" data-uid="${topicObj.user.uid}" src="${topicObj.user.picture}" class="user-img" title="${topicObj.user.displayname}" />`);
done();
});
@@ -251,4 +312,34 @@ describe('helpers', () => {
assert.equal(html, '<i class="fa fa-fw fa-question-circle"></i><i class="fa fa-fw fa-question-circle"></i>');
done();
});
it('should generate replied to or wrote based on toPid', (done) => {
const now = Date.now();
const iso = new Date().toISOString();
let post = { pid: 2, toPid: 1, timestamp: now, timestampISO: iso, parent: { displayname: 'baris' } };
let str = helpers.generateWroteReplied(post, 1);
assert.strictEqual(str, `[[topic:replied-to-user-ago, 1, ${nconf.get('relative_path')}/post/1, baris, ${nconf.get('relative_path')}/post/2, ${iso}]]`);
post = { pid: 2, toPid: 1, timestamp: now, timestampISO: iso, parent: { displayname: 'baris' } };
str = helpers.generateWroteReplied(post, -1);
assert.strictEqual(str, `[[topic:replied-to-user-on, 1, ${nconf.get('relative_path')}/post/1, baris, ${nconf.get('relative_path')}/post/2, ${iso}]]`);
post = { pid: 2, timestamp: now, timestampISO: iso, parent: { displayname: 'baris' } };
str = helpers.generateWroteReplied(post, 1);
assert.strictEqual(str, `[[topic:wrote-ago, ${nconf.get('relative_path')}/post/2, ${iso}]]`);
str = helpers.generateWroteReplied(post, -1);
assert.strictEqual(str, `[[topic:wrote-on, ${nconf.get('relative_path')}/post/2, ${iso}]]`);
done();
});
it('should generate placeholder wave', (done) => {
const items = [2, 'divider', 3];
const str = helpers.generatePlaceholderWave(items);
assert(str.includes('dropdown-divider'));
assert(str.includes('col-2'));
assert(str.includes('col-3'));
done();
});
});

View File

@@ -41,6 +41,36 @@ describe('Translator shim', () => {
assert.strictEqual(t, 'secret');
});
});
describe('translateKeys', () => {
it('should translate each key in array', async () => {
const translated = await shim.translateKeys(['[[global:home]]', '[[global:search]]'], 'en-GB');
assert.deepStrictEqual(translated, ['Home', 'Search']);
});
it('should translate each key in array using a callback', (done) => {
shim.translateKeys(['[[global:save]]', '[[global:close]]'], 'en-GB', (translated) => {
assert.deepStrictEqual(translated, ['Save', 'Close']);
done();
});
});
});
it('should load translations for language', (done) => {
shim.load('en-GB', 'global', (translations) => {
assert(translations);
assert(translations['header.profile']);
done();
});
});
it('should get translations for language', (done) => {
shim.getTranslations('en-GB', 'global', (translations) => {
assert(translations);
assert(translations['header.profile']);
done();
});
});
});
describe('new Translator(language)', () => {

124
test/user/custom-fields.js Normal file
View File

@@ -0,0 +1,124 @@
'use strict';
const nconf = require('nconf');
const assert = require('assert');
const async = require('async');
const db = require('../mocks/databasemock');
const user = require('../../src/user');
const groups = require('../../src/groups');
const request = require('../../src/request');
const socketUser = require('../../src/socket.io/user');
const adminUser = require('../../src/socket.io/admin/user');
describe('custom user fields', () => {
let adminUid;
let lowRepUid;
let highRepUid;
before(async () => {
adminUid = await user.create({ username: 'admin' });
await groups.join('administrators', adminUid);
lowRepUid = await user.create({ username: 'lowRepUser' });
highRepUid = await user.create({ username: 'highRepUser' });
await db.setObjectField(`user:${highRepUid}`, 'reputation', 10);
await db.sortedSetAdd(`users:reputation`, 10, highRepUid);
});
it('should create custom user fields', async () => {
const fields = [
{ key: 'website', icon: 'fa-solid fa-globe', name: 'Website', type: 'input-link', visibility: 'all', 'min:rep': 0 },
{ key: 'location', icon: 'fa-solid fa-pin', name: 'Location', type: 'input-text', visibility: 'all', 'min:rep': 0 },
{ key: 'favouriteDate', icon: '', name: 'Anniversary', type: 'input-date', visibility: 'all', 'min:rep': 0 },
{ key: 'favouriteLanguages', icon: 'fa-solid fa-code', name: 'Favourite Languages', type: 'select-multi', visibility: 'all', 'min:rep': 0, 'select-options': 'C++\nC\nJavascript\nPython\nAssembly' },
{ key: 'luckyNumber', icon: 'fa-solid fa-dice', name: 'Lucky Number', type: 'input-number', visibility: 'privileged', 'min:rep': 7 },
{ key: 'soccerTeam', icon: 'fa-regular fa-futbol', name: 'Soccer Team', type: 'select', visibility: 'all', 'min:rep': 0, 'select-options': 'Barcelona\nLiverpool\nArsenal\nGalatasaray\n' },
];
await adminUser.saveCustomFields({ uid: adminUid }, fields);
});
it('should fail to update a field if user does not have enough reputation', async () => {
await assert.rejects(
user.updateProfile(lowRepUid, {
uid: lowRepUid,
luckyNumber: 13,
}),
{ message: '[[error:not-enough-reputation-custom-field, 7, Lucky Number]]' },
);
});
it('should fail with invalid field data', async () => {
await assert.rejects(
user.updateProfile(highRepUid, {
uid: highRepUid,
location: new Array(300).fill('a').join(''),
}),
{ message: '[[error:custom-user-field-value-too-long, Location]]' },
);
await assert.rejects(
user.updateProfile(highRepUid, {
uid: highRepUid,
luckyNumber: 'not-a-number',
}),
{ message: '[[error:custom-user-field-invalid-number, Lucky Number]]' },
);
await assert.rejects(
user.updateProfile(highRepUid, {
uid: highRepUid,
location: 'https://spam.com',
}),
{ message: '[[error:custom-user-field-invalid-text, Location]]' },
);
await assert.rejects(
user.updateProfile(highRepUid, {
uid: highRepUid,
favouriteDate: 'not-a-date',
}),
{ message: '[[error:custom-user-field-invalid-date, Anniversary]]' },
);
await assert.rejects(
user.updateProfile(highRepUid, {
uid: highRepUid,
website: 'not-a-url',
}),
{ message: '[[error:custom-user-field-invalid-link, Website]]' },
);
await assert.rejects(
user.updateProfile(highRepUid, {
uid: highRepUid,
soccerTeam: 'not-in-options',
}),
{ message: '[[error:custom-user-field-select-value-invalid, Soccer Team]]' },
);
await assert.rejects(
user.updateProfile(highRepUid, {
uid: highRepUid,
favouriteLanguages: '["not-in-options"]',
}),
{ message: '[[error:custom-user-field-select-value-invalid, Favourite Languages]]' },
);
});
it('should update a users custom fields if they have enough reputation', async () => {
await user.updateProfile(highRepUid, {
uid: highRepUid,
website: 'https://nodebb.org',
location: 'Toronto',
favouriteDate: '2014-05-01',
favouriteLanguages: '["Javascript", "Python"]',
luckyNumber: 13,
soccerTeam: 'Galatasaray',
});
const { body } = await request.get(`${nconf.get('url')}/api/user/highrepuser`);
assert.strictEqual(body.website, 'https://nodebb.org');
});
});

View File

@@ -199,10 +199,9 @@ describe('Utility Methods', () => {
utils.assertPasswordValidity('Yzsh31j!a', zxcvbn);
});
// it('should generate UUID', () => {
// TODO: add back when nodejs 18 is minimum
// assert(validator.isUUID(utils.generateUUID()));
// });
it('should generate UUID', () => {
assert(validator.isUUID(utils.generateUUID()));
});
it('should shallow merge two objects', (done) => {
const a = { foo: 1, cat1: 'ginger' };
@@ -502,4 +501,77 @@ describe('Utility Methods', () => {
assert.strictEqual(result.user1.uid, uid1);
assert.strictEqual(result.user2.uid, uid2);
});
describe('debounce/throttle', () => {
it('should call function after x milliseconds once', (done) => {
let count = 0;
const now = Date.now();
const fn = utils.debounce(() => {
count += 1;
assert.strictEqual(count, 1);
assert(Date.now() - now > 50);
}, 100);
fn();
fn();
setTimeout(() => done(), 200);
});
it('should call function first if immediate=true', (done) => {
let count = 0;
const now = Date.now();
const fn = utils.debounce(() => {
count += 1;
assert.strictEqual(count, 1);
assert(Date.now() - now < 50);
}, 100, true);
fn();
fn();
setTimeout(() => done(), 200);
});
it('should call function after x milliseconds once', (done) => {
let count = 0;
const now = Date.now();
const fn = utils.throttle(() => {
count += 1;
assert.strictEqual(count, 1);
assert(Date.now() - now > 50);
}, 100);
fn();
fn();
setTimeout(() => done(), 200);
});
it('should call function twice if immediate=true', (done) => {
let count = 0;
const fn = utils.throttle(() => {
count += 1;
}, 100, true);
fn();
fn();
setTimeout(() => {
assert.strictEqual(count, 2);
done();
}, 200);
});
});
describe('Translator', () => {
const shim = require('../src/translator');
const { Translator } = shim;
it('should translate in place', async () => {
const translator = Translator.create('en-GB');
const el = $(`<div><span id="search" title="[[global:search]]"></span><span id="text">[[global:home]]</span></div>`);
await translator.translateInPlace(el.get(0));
assert.strictEqual(el.find('#text').text(), 'Home');
assert.strictEqual(el.find('#search').attr('title'), 'Search');
});
it('should not error', (done) => {
shim.flush();
shim.flushNamespace();
done();
});
});
});