mirror of
https://github.com/NodeBB/NodeBB.git
synced 2026-05-06 20:36:29 +02:00
Merge commit '9592c1762bd5e6f4b492e7fd84297bfddae92898' into v4.x
This commit is contained in:
61
CHANGELOG.md
61
CHANGELOG.md
@@ -1,3 +1,64 @@
|
||||
#### v4.10.1 (2026-03-25)
|
||||
|
||||
##### Chores
|
||||
|
||||
* incrementing version number - v4.10.0 (5b703104)
|
||||
* update changelog for v4.10.0 (c480df9e)
|
||||
* incrementing version number - v4.9.2 (e6846052)
|
||||
* incrementing version number - v4.9.1 (72e44c86)
|
||||
* incrementing version number - v4.9.0 (3fdd1bef)
|
||||
* incrementing version number - v4.8.1 (713ae0c0)
|
||||
* incrementing version number - v4.8.0 (3fac737a)
|
||||
* incrementing version number - v4.7.2 (cd419d8a)
|
||||
* incrementing version number - v4.7.1 (afb88805)
|
||||
* incrementing version number - v4.7.0 (e82d40f8)
|
||||
* incrementing version number - v4.6.3 (9fc5b0f3)
|
||||
* incrementing version number - v4.6.2 (f98747db)
|
||||
* incrementing version number - v4.6.1 (f47aa678)
|
||||
* incrementing version number - v4.6.0 (ee395bc5)
|
||||
* incrementing version number - v4.5.2 (ad2da639)
|
||||
* incrementing version number - v4.5.1 (69f4b61f)
|
||||
* incrementing version number - v4.5.0 (f05c5d06)
|
||||
* incrementing version number - v4.4.6 (074043ad)
|
||||
* incrementing version number - v4.4.5 (6f106923)
|
||||
* incrementing version number - v4.4.4 (d323af44)
|
||||
* incrementing version number - v4.4.3 (d354c2eb)
|
||||
* incrementing version number - v4.4.2 (55c510ae)
|
||||
* incrementing version number - v4.4.1 (5ae79b4e)
|
||||
* incrementing version number - v4.4.0 (0a75eee3)
|
||||
* incrementing version number - v4.3.2 (b92b5d80)
|
||||
* incrementing version number - v4.3.1 (308e6b9f)
|
||||
* incrementing version number - v4.3.0 (bff291db)
|
||||
* incrementing version number - v4.2.2 (17fecc24)
|
||||
* incrementing version number - v4.2.1 (852a270c)
|
||||
* incrementing version number - v4.2.0 (87581958)
|
||||
* incrementing version number - v4.1.1 (b2afbb16)
|
||||
* incrementing version number - v4.1.0 (36c80850)
|
||||
* incrementing version number - v4.0.6 (4a52fb2e)
|
||||
* incrementing version number - v4.0.5 (1792a62b)
|
||||
* incrementing version number - v4.0.4 (b1125cce)
|
||||
* 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 email share (43e7f0ab)
|
||||
|
||||
##### Bug Fixes
|
||||
|
||||
* #14123, aria-hidden fixes (72f48fd9)
|
||||
* #14121, use normalizedPath when uploading (a10471fc)
|
||||
* key name (52e42685)
|
||||
* #14108, reset filter on notif dropdown open (ad1433e1)
|
||||
* #14116, don't return ban reason if login credentials are incorrect (9bcef6b5)
|
||||
* share url for ap posts, fallback to window.location.href if pid doesnt exist (361134f9)
|
||||
|
||||
##### Refactors
|
||||
|
||||
* work with different line-clamp values (9b885162)
|
||||
|
||||
#### v4.10.0 (2026-03-19)
|
||||
|
||||
##### Chores
|
||||
|
||||
@@ -108,10 +108,10 @@
|
||||
"nodebb-plugin-spam-be-gone": "2.3.2",
|
||||
"nodebb-plugin-web-push": "0.7.7",
|
||||
"nodebb-rewards-essentials": "1.0.2",
|
||||
"nodebb-theme-harmony": "2.2.62",
|
||||
"nodebb-theme-harmony": "2.2.63",
|
||||
"nodebb-theme-lavender": "7.1.21",
|
||||
"nodebb-theme-peace": "2.2.57",
|
||||
"nodebb-theme-persona": "14.2.33",
|
||||
"nodebb-theme-peace": "2.2.58",
|
||||
"nodebb-theme-persona": "14.2.34",
|
||||
"nodebb-widget-essentials": "7.0.43",
|
||||
"nodemailer": "8.0.3",
|
||||
"nprogress": "0.2.0",
|
||||
|
||||
@@ -71,6 +71,11 @@ get:
|
||||
type: boolean
|
||||
readClass:
|
||||
type: string
|
||||
unreadNids:
|
||||
type: array
|
||||
description: An array of notification ids that are unread.
|
||||
items:
|
||||
type: string
|
||||
filters:
|
||||
$ref: ../components/schemas/NotificationFilters.yaml#/FiltersArray
|
||||
regularFilters:
|
||||
|
||||
@@ -17,6 +17,7 @@ module.exports = function (utils, Benchpress, relative_path) {
|
||||
generateCategoryBackground,
|
||||
generateChildrenCategories,
|
||||
generateTopicClass,
|
||||
generateGroupDisplayName,
|
||||
membershipBtn,
|
||||
spawnPrivilegeStates,
|
||||
localeToHTML,
|
||||
@@ -167,6 +168,10 @@ module.exports = function (utils, Benchpress, relative_path) {
|
||||
return fields.filter(field => !!topic[field]).join(' ');
|
||||
}
|
||||
|
||||
function generateGroupDisplayName(group) {
|
||||
return group.system ? group.displayName.replace(/-/g, ' ') : group.displayName;
|
||||
}
|
||||
|
||||
// Groups helpers
|
||||
function membershipBtn(groupObj, btnClass = '') {
|
||||
if (groupObj.isMember && groupObj.name !== 'administrators') {
|
||||
|
||||
@@ -38,11 +38,7 @@ Analytics.init = async function () {
|
||||
runOnAllNodes: true,
|
||||
onTick: async () => {
|
||||
if (Analytics.pause) return;
|
||||
publishLocalAnalytics();
|
||||
if (runJobs) {
|
||||
await sleep(2000);
|
||||
await Analytics.writeData();
|
||||
}
|
||||
await Analytics.writeLocalData();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -63,6 +59,14 @@ Analytics.init = async function () {
|
||||
}
|
||||
};
|
||||
|
||||
Analytics.writeLocalData = async function () {
|
||||
publishLocalAnalytics();
|
||||
if (runJobs) {
|
||||
await sleep(2000);
|
||||
await Analytics.writeData();
|
||||
}
|
||||
};
|
||||
|
||||
function publishLocalAnalytics() {
|
||||
pubsub.publish('analytics:publish', {
|
||||
local: local,
|
||||
@@ -185,6 +189,12 @@ Analytics.writeData = async function () {
|
||||
incrByBulk.push(['analytics:pageviews:ap', total.apPageViews, today.getTime()]);
|
||||
incrByBulk.push(['analytics:pageviews:ap:month', total.apPageViews, month.getTime()]);
|
||||
total.apPageViews = 0;
|
||||
if (!metrics.includes('pageviews:ap')) {
|
||||
metrics.push('pageviews:ap');
|
||||
}
|
||||
if (!metrics.includes('pageviews:ap:month')) {
|
||||
metrics.push('pageviews:ap:month');
|
||||
}
|
||||
}
|
||||
|
||||
if (total.uniquevisitors > 0) {
|
||||
|
||||
@@ -148,8 +148,11 @@ async function upgradePlugins(unattended = false) {
|
||||
if (['y', 'Y', 'yes', 'YES'].includes(result.upgrade)) {
|
||||
console.log('\nUpgrading packages...');
|
||||
const args = packageManagerInstallArgs.concat(found.map(suggestObj => `${suggestObj.name}@${suggestObj.suggested}`));
|
||||
|
||||
cproc.execFileSync(packageManagerExecutable, args, { stdio: 'ignore' });
|
||||
const options = { stdio: 'ignore' };
|
||||
if (process.platform === 'win32') {
|
||||
options.shell = true;
|
||||
}
|
||||
cproc.execFileSync(packageManagerExecutable, args, options);
|
||||
} else {
|
||||
console.log(`${chalk.yellow('Package upgrades skipped')}. Check for upgrades at any time by running "${chalk.green('./nodebb upgrade -p')}".`);
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ notificationsController.get = async function (req, res, next) {
|
||||
const data = await user.notifications.getAllWithCounts(req.uid, selectedFilter.filter);
|
||||
let notifications = await user.notifications.getNotifications(data.nids, req.uid);
|
||||
|
||||
const unreadNids = notifications.filter(n => n && n.nid && !n.read).map(n => n.nid);
|
||||
allFilters.forEach((filterData) => {
|
||||
if (filterData && filterData.filter) {
|
||||
filterData.count = data.counts[filterData.filter] || 0;
|
||||
@@ -72,6 +73,7 @@ notificationsController.get = async function (req, res, next) {
|
||||
|
||||
res.render('notifications', {
|
||||
notifications: notifications,
|
||||
unreadNids,
|
||||
pagination: pagination.create(page, pageCount, req.query),
|
||||
filters: allFilters,
|
||||
regularFilters: regularFilters,
|
||||
|
||||
@@ -221,6 +221,7 @@ authenticationController.login = async (req, res, next) => {
|
||||
}
|
||||
|
||||
const loginWith = meta.config.allowLoginWith || 'username-email';
|
||||
req.body = req.body || {};
|
||||
req.body.username = String(req.body.username).trim();
|
||||
const errorHandler = res.locals.noScriptErrors || helpers.noScriptErrors;
|
||||
try {
|
||||
|
||||
@@ -20,6 +20,7 @@ const activitypub = require('../activitypub');
|
||||
const topicsController = module.exports;
|
||||
|
||||
const url = nconf.get('url');
|
||||
const base_url = nconf.get('base_url');
|
||||
const relative_path = nconf.get('relative_path');
|
||||
const upload_url = nconf.get('upload_url');
|
||||
const validSorts = ['oldest_to_newest', 'newest_to_oldest', 'most_votes'];
|
||||
@@ -356,8 +357,13 @@ function addOGImageTag(res, image) {
|
||||
}
|
||||
|
||||
if (!imageUrl.startsWith('http')) {
|
||||
// (https://domain.com/forum) + (/assets/uploads) + (/files/imagePath)
|
||||
imageUrl = url + path.posix.join(upload_url, imageUrl);
|
||||
if (imageUrl.startsWith(`${relative_path}${upload_url}`)) {
|
||||
// (https://domain.com) + imageUrl (which starts with /relative_path/upload_url)
|
||||
imageUrl = base_url + imageUrl;
|
||||
} else {
|
||||
// (https://domain.com/forum) + (/assets/uploads) + (/files/imagePath)
|
||||
imageUrl = url + path.posix.join(upload_url, imageUrl);
|
||||
}
|
||||
}
|
||||
|
||||
res.locals.metaTags.push({
|
||||
|
||||
@@ -83,7 +83,9 @@ module.exports = function (module) {
|
||||
if (!utils.isNumber(item[1])) {
|
||||
throw new Error(`[[error:invalid-score, ${item[1]}]]`);
|
||||
}
|
||||
bulk.find({ _key: item[0], value: String(item[2]) }).upsert().updateOne({ $set: { score: parseFloat(item[1]) } });
|
||||
bulk.find({ _key: item[0], value: String(item[2]) })
|
||||
.upsert()
|
||||
.updateOne({ $set: { score: parseFloat(item[1]) } });
|
||||
});
|
||||
await bulk.execute();
|
||||
};
|
||||
|
||||
@@ -27,31 +27,27 @@ DELETE FROM "legacy_object"
|
||||
AND "expireAt" <= CURRENT_TIMESTAMP`,
|
||||
});
|
||||
|
||||
await db.query({
|
||||
name: 'ensureLegacyObjectType1',
|
||||
const res = await tryUpsert(db, {
|
||||
name: 'ensureLegacyObjectType_upsert',
|
||||
text: `
|
||||
INSERT INTO "legacy_object" ("_key", "type")
|
||||
VALUES ($1::TEXT, $2::TEXT::LEGACY_OBJECT_TYPE)
|
||||
ON CONFLICT
|
||||
DO NOTHING`,
|
||||
INSERT INTO "legacy_object" ("_key", "type")
|
||||
VALUES ($1::TEXT, $2::TEXT::LEGACY_OBJECT_TYPE)
|
||||
ON CONFLICT ("_key")
|
||||
DO UPDATE SET "type" = "legacy_object"."type"
|
||||
RETURNING "type"`,
|
||||
values: [key, type],
|
||||
});
|
||||
|
||||
const res = await db.query({
|
||||
name: 'ensureLegacyObjectType2',
|
||||
text: `
|
||||
SELECT "type"
|
||||
FROM "legacy_object_live"
|
||||
WHERE "_key" = $1::TEXT`,
|
||||
values: [key],
|
||||
});
|
||||
|
||||
if (res.rows[0].type !== type) {
|
||||
throw new Error(`database: cannot insert ${JSON.stringify(key)} as ${type} because it already exists as ${res.rows[0].type}`);
|
||||
}
|
||||
};
|
||||
|
||||
helpers.ensureLegacyObjectsType = async function (db, keys, type) {
|
||||
keys = [...new Set(keys)];
|
||||
if (!keys.length) {
|
||||
return;
|
||||
}
|
||||
await db.query({
|
||||
name: 'ensureLegacyObjectTypeBefore',
|
||||
text: `
|
||||
@@ -60,38 +56,45 @@ DELETE FROM "legacy_object"
|
||||
AND "expireAt" <= CURRENT_TIMESTAMP`,
|
||||
});
|
||||
|
||||
await db.query({
|
||||
name: 'ensureLegacyObjectsType1',
|
||||
const res = await tryUpsert(db, {
|
||||
name: 'ensureLegacyObjectsType_upsert',
|
||||
text: `
|
||||
INSERT INTO "legacy_object" ("_key", "type")
|
||||
SELECT k, $2::TEXT::LEGACY_OBJECT_TYPE
|
||||
FROM UNNEST($1::TEXT[]) k
|
||||
ON CONFLICT
|
||||
DO NOTHING`,
|
||||
FROM UNNEST($1::TEXT[]) k
|
||||
ON CONFLICT ("_key")
|
||||
DO UPDATE SET "type" = "legacy_object"."type"
|
||||
RETURNING "_key", "type"`,
|
||||
values: [keys, type],
|
||||
});
|
||||
|
||||
const res = await db.query({
|
||||
name: 'ensureLegacyObjectsType2',
|
||||
text: `
|
||||
SELECT "_key", "type"
|
||||
FROM "legacy_object_live"
|
||||
WHERE "_key" = ANY($1::TEXT[])`,
|
||||
values: [keys],
|
||||
});
|
||||
|
||||
const invalid = res.rows.filter(r => r.type !== type);
|
||||
|
||||
if (invalid.length) {
|
||||
const parts = invalid.map(r => `${JSON.stringify(r._key)} is ${r.type}`);
|
||||
throw new Error(`database: cannot insert multiple objects as ${type} because they already exist: ${parts.join(', ')}`);
|
||||
}
|
||||
|
||||
const missing = keys.filter(k => !res.rows.some(r => r._key === k));
|
||||
|
||||
if (missing.length) {
|
||||
throw new Error(`database: failed to insert keys for objects: ${JSON.stringify(missing)}`);
|
||||
}
|
||||
};
|
||||
|
||||
async function tryUpsert(db, queryConfig) {
|
||||
let res;
|
||||
const savepoint = `upsert_${Math.random().toString(36).substring(7)}`;
|
||||
try {
|
||||
await db.query(`SAVEPOINT ${savepoint}`);
|
||||
res = await db.query(queryConfig);
|
||||
await db.query(`RELEASE SAVEPOINT ${savepoint}`);
|
||||
} catch (err) {
|
||||
if (err.code === '23505') { // retry if failed due to error: unique constraint
|
||||
// Roll back to the savepoint to prevent
|
||||
// error: current transaction is aborted, commands ignored until end of transaction block
|
||||
await db.query(`ROLLBACK TO SAVEPOINT ${savepoint}`);
|
||||
res = await db.query(queryConfig);
|
||||
await db.query(`RELEASE SAVEPOINT ${savepoint}`);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
helpers.noop = function () {};
|
||||
|
||||
@@ -551,16 +551,29 @@ RETURNING "score" s`,
|
||||
return [];
|
||||
}
|
||||
|
||||
// Deduplicate by (key, value) pair, summing increments for duplicates
|
||||
const seen = new Map();
|
||||
const deduped = [];
|
||||
data.forEach(([key, increment, value]) => {
|
||||
value = helpers.valueToString(value);
|
||||
increment = parseFloat(increment);
|
||||
const mapKey = `${key}\0${value}`;
|
||||
if (seen.has(mapKey)) {
|
||||
deduped[seen.get(mapKey)][1] += increment;
|
||||
} else {
|
||||
seen.set(mapKey, deduped.length);
|
||||
deduped.push([key, increment, value]);
|
||||
}
|
||||
});
|
||||
|
||||
return await module.transaction(async (client) => {
|
||||
await helpers.ensureLegacyObjectsType(client, data.map(item => item[0]), 'zset');
|
||||
await helpers.ensureLegacyObjectsType(client, deduped.map(item => item[0]), 'zset');
|
||||
|
||||
const values = [];
|
||||
const queryParams = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
data.forEach(([key, increment, value]) => {
|
||||
value = helpers.valueToString(value);
|
||||
increment = parseFloat(increment);
|
||||
deduped.forEach(([key, increment, value]) => {
|
||||
values.push(key, value, increment);
|
||||
queryParams.push(`($${paramIndex}::TEXT, $${paramIndex + 1}::TEXT, $${paramIndex + 2}::NUMERIC)`);
|
||||
paramIndex += 3;
|
||||
|
||||
@@ -114,8 +114,10 @@ INSERT INTO "legacy_zset" ("_key", "value", "score")
|
||||
}
|
||||
keys.push(item[0]);
|
||||
scores.push(item[1]);
|
||||
values.push(item[2]);
|
||||
values.push(helpers.valueToString(item[2]));
|
||||
});
|
||||
const compositeKeys = keys.map((k, i) => `${k}\0${values[i]}`);
|
||||
helpers.removeDuplicateValues(compositeKeys, keys, values, scores);
|
||||
await module.transaction(async (client) => {
|
||||
await helpers.ensureLegacyObjectsType(client, keys, 'zset');
|
||||
await client.query({
|
||||
|
||||
@@ -133,9 +133,6 @@ module.exports = function (Groups) {
|
||||
if (hasField('name')) {
|
||||
group.nameEncoded = encodeURIComponent(group.name);
|
||||
group.displayName = validator.escape(String(group.name));
|
||||
if (Groups.systemGroups.includes(group.name)) {
|
||||
group.displayName = group.displayName.replace(/-/g, ' ');
|
||||
}
|
||||
}
|
||||
if (hasField('description')) {
|
||||
group.description = validator.escape(String(group.description || ''));
|
||||
|
||||
@@ -6,6 +6,7 @@ const path = require('path');
|
||||
const winston = require('winston');
|
||||
|
||||
const db = require('../database');
|
||||
const file = require('../file');
|
||||
const pubsub = require('../pubsub');
|
||||
const Meta = require('./index');
|
||||
const translator = require('../translator');
|
||||
@@ -212,20 +213,17 @@ async function getLogoSize(data) {
|
||||
if (!data['brand:logo']) {
|
||||
return;
|
||||
}
|
||||
const x50Path = path.join(nconf.get('upload_path'), 'system', 'site-logo-x50.png');
|
||||
let size;
|
||||
try {
|
||||
size = await image.size(path.join(nconf.get('upload_path'), 'system', 'site-logo-x50.png'));
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
// For whatever reason the x50 logo wasn't generated, gracefully error out
|
||||
winston.warn('[logo] The email-safe logo doesn\'t seem to have been created, please re-upload your site logo.');
|
||||
size = {
|
||||
height: 0,
|
||||
width: 0,
|
||||
};
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
if (await file.exists(x50Path)) {
|
||||
size = await image.size(x50Path);
|
||||
} else {
|
||||
// For whatever reason the x50 logo wasn't generated, gracefully error out
|
||||
winston.warn('[logo] The email-safe logo doesn\'t seem to have been created, please re-upload your site logo.');
|
||||
size = {
|
||||
height: 0,
|
||||
width: 0,
|
||||
};
|
||||
}
|
||||
data['brand:emailLogo'] = nconf.get('url') + path.join(nconf.get('upload_url'), 'system', 'site-logo-x50.png');
|
||||
data['brand:emailLogo:height'] = size.height;
|
||||
|
||||
@@ -21,6 +21,7 @@ Tags.parse = async (req, data, meta, link) => {
|
||||
name: 'viewport',
|
||||
// https://stackoverflow.com/a/77815388 for resizes-content
|
||||
content: 'width=device-width, initial-scale=1.0, interactive-widget=resizes-content',
|
||||
noEscape: true,
|
||||
}, {
|
||||
name: 'content-type',
|
||||
content: 'text/html; charset=UTF-8',
|
||||
|
||||
@@ -17,14 +17,22 @@ const controllers = {
|
||||
const middleware = module.exports;
|
||||
|
||||
middleware.buildHeader = helpers.try(async (req, res, next) => {
|
||||
res.locals.renderAdminHeader = true;
|
||||
await doBuildHeader(req, res);
|
||||
next();
|
||||
});
|
||||
|
||||
middleware.buildHeaderAsync = async (req, res) => {
|
||||
await doBuildHeader(req, res);
|
||||
};
|
||||
|
||||
async function doBuildHeader(req, res) {
|
||||
res.locals.renderHeaderType = 'admin';
|
||||
if (req.method === 'GET') {
|
||||
await require('./index').applyCSRFasync(req, res);
|
||||
}
|
||||
|
||||
await plugins.hooks.fire('filter:middleware.buildAdminHeader', { req: req, locals: res.locals });
|
||||
res.locals.config = await controllers.admin.loadConfig(req);
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
middleware.checkPrivileges = helpers.try(async (req, res, next) => {
|
||||
// Kick out guests, obviously
|
||||
|
||||
@@ -19,7 +19,7 @@ middleware.buildHeaderAsync = async (req, res) => {
|
||||
};
|
||||
|
||||
async function doBuildHeader(req, res) {
|
||||
res.locals.renderHeader = true;
|
||||
res.locals.renderHeaderType = 'client';
|
||||
res.locals.isAPI = false;
|
||||
if (req.method === 'GET') {
|
||||
await require('./index').applyCSRFasync(req, res);
|
||||
|
||||
@@ -139,9 +139,9 @@ module.exports = function (middleware) {
|
||||
}
|
||||
|
||||
async function loadHeaderFooterData(req, res, options) {
|
||||
if (res.locals.renderHeader) {
|
||||
if (res.locals.renderHeaderType === 'client') {
|
||||
return await loadClientHeaderFooterData(req, res, options);
|
||||
} else if (res.locals.renderAdminHeader) {
|
||||
} else if (res.locals.renderHeaderType === 'admin') {
|
||||
return await loadAdminHeaderFooterData(req, res, options);
|
||||
}
|
||||
return null;
|
||||
@@ -382,13 +382,13 @@ module.exports = function (middleware) {
|
||||
|
||||
async function renderHeaderFooter(method, req, res, options, headerFooterData) {
|
||||
let str = '';
|
||||
if (res.locals.renderHeader) {
|
||||
if (res.locals.renderHeaderType === 'client') {
|
||||
if (method === 'renderHeader') {
|
||||
str = await renderHeader(req, res, options, headerFooterData);
|
||||
} else if (method === 'renderFooter') {
|
||||
str = await renderFooter(req, res, options, headerFooterData);
|
||||
}
|
||||
} else if (res.locals.renderAdminHeader) {
|
||||
} else if (res.locals.renderHeaderType === 'admin') {
|
||||
if (method === 'renderHeader') {
|
||||
str = await renderAdminHeader(req, res, options, headerFooterData);
|
||||
} else if (method === 'renderFooter') {
|
||||
@@ -400,7 +400,7 @@ module.exports = function (middleware) {
|
||||
|
||||
function getLang(req, res) {
|
||||
let language = (res.locals.config && res.locals.config.userLang) || 'en-GB';
|
||||
if (res.locals.renderAdminHeader) {
|
||||
if (res.locals.renderHeaderType === 'admin') {
|
||||
language = (res.locals.config && res.locals.config.acpLang) || 'en-GB';
|
||||
}
|
||||
return req.query.lang ? validator.escape(String(req.query.lang)) : language;
|
||||
|
||||
@@ -149,7 +149,7 @@ async function shutdown(code) {
|
||||
try {
|
||||
await require('./webserver').destroy();
|
||||
winston.info('[app] Web server closed to connections.');
|
||||
await require('./analytics').writeData();
|
||||
await require('./analytics').writeLocalData();
|
||||
winston.info('[app] Live analytics saved.');
|
||||
const db = require('./database');
|
||||
await db.delete('locks');
|
||||
|
||||
@@ -259,14 +259,14 @@ module.exports = function (Topics) {
|
||||
return postData;
|
||||
};
|
||||
|
||||
async function onNewPost({ pid, tid, uid: postOwner }, { uid, handle }) {
|
||||
async function onNewPost({ pid, tid, content, uid: postOwner }, { uid, handle }) {
|
||||
const [[postData], [userInfo]] = await Promise.all([
|
||||
posts.getPostSummaryByPids([pid], uid, { extraFields: ['attachments'] }),
|
||||
posts.getUserInfoForPosts([postOwner], uid),
|
||||
]);
|
||||
await Promise.all([
|
||||
Topics.addParentPosts([postData], uid),
|
||||
Topics.syncBacklinks(postData),
|
||||
Topics.syncBacklinks({ ...postData, content }),
|
||||
Topics.markAsRead([tid], uid),
|
||||
]);
|
||||
if (utils.isNumber(postOwner) && postData.category.cid === -1) {
|
||||
|
||||
@@ -14,7 +14,7 @@ const plugins = require('../plugins');
|
||||
const utils = require('../utils');
|
||||
const privileges = require('../privileges');
|
||||
|
||||
const backlinkRegex = new RegExp(`(?:${nconf.get('url').replace('/', '\\/')}|\b|\\s)\\/topic\\/(\\d+)(?:\\/\\w+)?`, 'g');
|
||||
const backlinkRegex = new RegExp(`(?:${nconf.get('url').replace('/', '\\/')}|\b|\\s)\\/topic\\/([a-fA-F0-9-]+)(?=\\/|$|\\s)`, 'g');
|
||||
|
||||
module.exports = function (Topics) {
|
||||
Topics.onNewPostMade = async function (postData) {
|
||||
@@ -447,29 +447,27 @@ module.exports = function (Topics) {
|
||||
throw new Error('[[error:invalid-data]]');
|
||||
}
|
||||
|
||||
|
||||
let { content } = postData;
|
||||
let { pid, uid, content } = postData;
|
||||
// ignore lines that start with `>`
|
||||
content = (content || '').split('\n').filter(line => !line.trim().startsWith('>')).join('\n');
|
||||
// Scan post content for topic links
|
||||
const matches = [...content.matchAll(backlinkRegex)];
|
||||
if (!matches) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const { pid, uid, tid } = postData;
|
||||
let add = _.uniq(matches.map(match => match[1]).map(tid => parseInt(tid, 10)));
|
||||
let add = _.uniq(matches.map(match => match[1]));
|
||||
|
||||
const now = Date.now();
|
||||
const topicsExist = await Topics.exists(add);
|
||||
const current = (await db.getSortedSetMembers(`pid:${pid}:backlinks`)).map(tid => parseInt(tid, 10));
|
||||
const [topicsExist, current] = await Promise.all([
|
||||
Topics.exists(add),
|
||||
db.getSortedSetMembers(`pid:${pid}:backlinks`),
|
||||
]);
|
||||
const remove = current.filter(tid => !add.includes(tid));
|
||||
add = add.filter((_tid, idx) => topicsExist[idx] && !current.includes(_tid) && tid !== _tid);
|
||||
const postTid = String(postData.tid);
|
||||
add = add.filter((_tid, idx) => topicsExist[idx] && !current.includes(_tid) && postTid !== _tid);
|
||||
|
||||
// Remove old backlinks
|
||||
await db.sortedSetRemove(`pid:${pid}:backlinks`, remove);
|
||||
|
||||
// Add new backlinks
|
||||
const now = Date.now();
|
||||
await db.sortedSetAdd(`pid:${pid}:backlinks`, add.map(() => now), add);
|
||||
await Promise.all(add.map(async (tid) => {
|
||||
await Topics.events.log(tid, {
|
||||
@@ -479,6 +477,6 @@ module.exports = function (Topics) {
|
||||
});
|
||||
}));
|
||||
|
||||
return add.length + (current - remove);
|
||||
return add.length + (current.length - remove.length);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -144,7 +144,7 @@ module.exports = function (User) {
|
||||
const uploadedImage = await image.uploadImage(filename, `profile/uid-${updateUid}`, {
|
||||
uid: updateUid,
|
||||
path: normalizedPath,
|
||||
name: 'profileAvatar',
|
||||
name: `profileAvatar${extension}`,
|
||||
});
|
||||
|
||||
await User.updateProfile(callerUid, {
|
||||
|
||||
@@ -100,9 +100,9 @@
|
||||
</div>
|
||||
{{{ end }}}
|
||||
</div>
|
||||
<div class="small">
|
||||
<div class="text-sm lh-1">
|
||||
{{{ if posts.user.userslug}}}
|
||||
<a class="text-decoration-none" href="{config.relative_path}/uid/{posts.user.uid}">{buildAvatar(posts.user, "24px", true, "not-responsive")} {posts.user.username}</a>
|
||||
<a class="text-decoration-none d-flex align-items-center gap-1" href="{config.relative_path}/uid/{posts.user.uid}">{buildAvatar(posts.user, "24px", true, "not-responsive")} {posts.user.username}</a>
|
||||
{{{ else }}}
|
||||
{posts.user.username}
|
||||
{{{ end }}}
|
||||
|
||||
@@ -284,6 +284,21 @@ describe('authentication', () => {
|
||||
assert.equal(response.status, 500);
|
||||
});
|
||||
|
||||
it('should fail to login if body is missing', async () => {
|
||||
const jar = request.jar();
|
||||
const csrf_token = await helpers.getCsrfToken(jar);
|
||||
|
||||
const { response, body } = await request.post(`${nconf.get('url')}/login`, {
|
||||
body: null,
|
||||
jar: jar,
|
||||
headers: {
|
||||
'x-csrf-token': csrf_token,
|
||||
},
|
||||
});
|
||||
assert.equal(response.status, 403);
|
||||
assert.strictEqual(body, '[[error:invalid-username-or-password]]');
|
||||
});
|
||||
|
||||
it('should fail to login if user does not exist', async () => {
|
||||
const { response, body } = await helpers.loginUser('doesnotexist', 'nopassword');
|
||||
assert.equal(response.statusCode, 403);
|
||||
|
||||
@@ -1152,12 +1152,10 @@ describe('Post\'s', () => {
|
||||
|
||||
describe('.syncBacklinks()', () => {
|
||||
it('should error on invalid data', async () => {
|
||||
try {
|
||||
await topics.syncBacklinks();
|
||||
} catch (e) {
|
||||
assert(e);
|
||||
assert.strictEqual(e.message, '[[error:invalid-data]]');
|
||||
}
|
||||
await assert.rejects(
|
||||
topics.syncBacklinks(),
|
||||
{ message: '[[error:invalid-data]]' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should do nothing if the post does not contain a link to a topic', async () => {
|
||||
@@ -1192,9 +1190,7 @@ describe('Post\'s', () => {
|
||||
const backlinks = await db.getSortedSetMembers('pid:2:backlinks');
|
||||
|
||||
assert.strictEqual(count, 0);
|
||||
assert(events);
|
||||
assert.strictEqual(events.length, 1);
|
||||
assert(backlinks);
|
||||
assert.strictEqual(backlinks.length, 0);
|
||||
});
|
||||
|
||||
@@ -1219,6 +1215,17 @@ describe('Post\'s', () => {
|
||||
assert(backlinks);
|
||||
assert.strictEqual(backlinks.length, 0);
|
||||
});
|
||||
|
||||
it('should not create a wrong backlink to topic/1 with AP topic url', async () => {
|
||||
const { postData } = await topics.post({
|
||||
uid: 1,
|
||||
cid,
|
||||
title: 'Topic backlink testing - topic 2',
|
||||
content: `testing ${nconf.get('url')}/topic/1aef954c-d0dc-45cf-acf2-e3a59f6cc134/foo`,
|
||||
});
|
||||
const backlinks = await db.getSortedSetMembers(`pid:${postData.pid}:backlinks`);
|
||||
assert.strictEqual(backlinks.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration tests', () => {
|
||||
|
||||
@@ -30,20 +30,14 @@ describe('socket.io', () => {
|
||||
let regularUid;
|
||||
|
||||
before(async () => {
|
||||
const data = await Promise.all([
|
||||
user.create({ username: 'admin', password: 'adminpwd' }),
|
||||
user.create({ username: 'regular', password: 'regularpwd', email: 'regular@test.com' }, { emailVerification: 'verify' }),
|
||||
categories.create({
|
||||
name: 'Test Category',
|
||||
description: 'Test category created by testing script',
|
||||
}),
|
||||
]);
|
||||
adminUid = data[0];
|
||||
adminUid = await user.create({ username: 'admin', password: 'adminpwd' });
|
||||
await groups.join('administrators', adminUid);
|
||||
regularUid = await user.create({ username: 'regular', password: 'regularpwd', email: 'regular@test.com' }, { emailVerification: 'verify' });
|
||||
({ cid } = await categories.create({
|
||||
name: 'Test Category',
|
||||
description: 'Test category created by testing script',
|
||||
}));
|
||||
|
||||
regularUid = data[1];
|
||||
|
||||
cid = data[2].cid;
|
||||
await topics.post({
|
||||
uid: adminUid,
|
||||
cid: cid,
|
||||
|
||||
Reference in New Issue
Block a user