mirror of
https://github.com/NodeBB/NodeBB.git
synced 2026-05-07 13:05:49 +02:00
Merge commit '7abdfd86ac87bcd4f5df7d17ae0cdf157177a991' into v4.x
This commit is contained in:
42
CHANGELOG.md
42
CHANGELOG.md
@@ -1,3 +1,45 @@
|
||||
#### v4.5.1 (2025-09-04)
|
||||
|
||||
##### Chores
|
||||
|
||||
* up dbsearch (c07e81d2)
|
||||
* incrementing version number - v4.5.0 (f05c5d06)
|
||||
* update changelog for v4.5.0 (86d03b1e)
|
||||
* 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
|
||||
|
||||
* use _variables.scss overrides from acp in custom skins and bootswatch skins as well (0c48e0e9)
|
||||
|
||||
##### Bug Fixes
|
||||
|
||||
* remove unused dependency (8d7e3537)
|
||||
* remove test for 1b12 announce on topic move (as this no longer occurs) (9221d34f)
|
||||
* use existing id if checkHeader returns false (e6996846)
|
||||
* regression that caused Piefed (or potentially others) content to be dropped on receipt (86d9016f)
|
||||
* remove faulty code that tried to announce a remote object but couldn't as the ID was not a number (7adfe39e)
|
||||
|
||||
#### v4.5.0 (2025-09-03)
|
||||
|
||||
##### Chores
|
||||
|
||||
@@ -96,8 +96,8 @@
|
||||
"mousetrap": "1.6.5",
|
||||
"multer": "2.0.2",
|
||||
"nconf": "0.13.0",
|
||||
"nodebb-plugin-2factor": "7.5.10",
|
||||
"nodebb-plugin-composer-default": "10.3.0",
|
||||
"nodebb-plugin-2factor": "7.6.0",
|
||||
"nodebb-plugin-composer-default": "10.3.1",
|
||||
"nodebb-plugin-dbsearch": "6.3.2",
|
||||
"nodebb-plugin-emoji": "6.0.3",
|
||||
"nodebb-plugin-emoji-android": "4.1.1",
|
||||
@@ -106,10 +106,10 @@
|
||||
"nodebb-plugin-spam-be-gone": "2.3.2",
|
||||
"nodebb-plugin-web-push": "0.7.5",
|
||||
"nodebb-rewards-essentials": "1.0.2",
|
||||
"nodebb-theme-harmony": "2.1.18",
|
||||
"nodebb-theme-harmony": "2.1.19",
|
||||
"nodebb-theme-lavender": "7.1.19",
|
||||
"nodebb-theme-peace": "2.2.48",
|
||||
"nodebb-theme-persona": "14.1.12",
|
||||
"nodebb-theme-persona": "14.1.14",
|
||||
"nodebb-widget-essentials": "7.0.40",
|
||||
"nodemailer": "7.0.6",
|
||||
"nprogress": "0.2.0",
|
||||
@@ -201,4 +201,4 @@
|
||||
"url": "https://github.com/barisusakli"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"federatedDescription": "Federated Description",
|
||||
"federatedDescription.help": "This text will be appended to the category description when queried by other websites/apps.",
|
||||
"federatedDescription.default": "This is a forum category containing topical discussion. You can start new discussions by mentioning this category.",
|
||||
"topic-template": "Topic Template",
|
||||
"topic-template.help": "Define a template for new topics created in this category.",
|
||||
"bg-color": "Background Colour",
|
||||
"text-color": "Text Colour",
|
||||
"bg-image-size": "Background Image Size",
|
||||
|
||||
@@ -60,6 +60,8 @@ get:
|
||||
type: string
|
||||
feeds:disableRSS:
|
||||
type: number
|
||||
reputation:disabled:
|
||||
type: number
|
||||
rssFeedUrl:
|
||||
type: string
|
||||
title:
|
||||
|
||||
@@ -58,6 +58,8 @@ get:
|
||||
type: string
|
||||
feeds:disableRSS:
|
||||
type: number
|
||||
reputation:disabled:
|
||||
type: number
|
||||
rssFeedUrl:
|
||||
type: string
|
||||
title:
|
||||
|
||||
@@ -71,6 +71,8 @@ get:
|
||||
type: string
|
||||
feeds:disableRSS:
|
||||
type: number
|
||||
reputation:disabled:
|
||||
type: number
|
||||
rssFeedUrl:
|
||||
type: string
|
||||
title:
|
||||
|
||||
@@ -19,6 +19,8 @@ get:
|
||||
type: boolean
|
||||
showTopicTools:
|
||||
type: boolean
|
||||
reputation:disabled:
|
||||
type: number
|
||||
nextStart:
|
||||
type: number
|
||||
topics:
|
||||
|
||||
@@ -441,7 +441,13 @@ define('navigator', [
|
||||
function generateUrl(index) {
|
||||
const pathname = window.location.pathname.replace(config.relative_path, '');
|
||||
const parts = pathname.split('/');
|
||||
return parts[1] + '/' + parts[2] + '/' + parts[3] + (index ? '/' + index : '');
|
||||
const newUrl = parts[1] + '/' + parts[2] + '/' + parts[3] + (index ? '/' + index : '');
|
||||
const data = {
|
||||
newUrl,
|
||||
index,
|
||||
};
|
||||
hooks.fire('action:navigator.generateUrl', data);
|
||||
return data.newUrl;
|
||||
}
|
||||
|
||||
navigator.getCount = () => count;
|
||||
|
||||
@@ -53,7 +53,7 @@ define('topicList', [
|
||||
|
||||
handleBack.init(function (after, handleBackCallback) {
|
||||
loadTopicsCallback(after, 1, function (data, loadCallback) {
|
||||
onTopicsLoaded(templateName, data.topics, ajaxify.data.showSelect, 1, function () {
|
||||
onTopicsLoaded(templateName, data, ajaxify.data.showSelect, 1, function () {
|
||||
handleBackCallback();
|
||||
loadCallback();
|
||||
});
|
||||
@@ -166,7 +166,7 @@ define('topicList', [
|
||||
}
|
||||
|
||||
loadTopicsCallback(after, direction, function (data, done) {
|
||||
onTopicsLoaded(templateName, data.topics, ajaxify.data.showSelect, direction, done);
|
||||
onTopicsLoaded(templateName, data, ajaxify.data.showSelect, direction, done);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -187,7 +187,8 @@ define('topicList', [
|
||||
});
|
||||
}
|
||||
|
||||
function onTopicsLoaded(templateName, topics, showSelect, direction, callback) {
|
||||
function onTopicsLoaded(templateName, data, showSelect, direction, callback) {
|
||||
let { topics } = data;
|
||||
if (!topics || !topics.length) {
|
||||
$('#load-more-btn').hide();
|
||||
return callback();
|
||||
@@ -212,6 +213,7 @@ define('topicList', [
|
||||
const tplData = {
|
||||
topics: topics,
|
||||
showSelect: showSelect,
|
||||
'reputation:disabled': data['reputation:disabled'],
|
||||
template: {
|
||||
name: templateName,
|
||||
},
|
||||
|
||||
@@ -298,6 +298,7 @@ inbox.announce = async (req) => {
|
||||
const exists = await posts.exists(localId || id);
|
||||
if (exists) {
|
||||
try {
|
||||
await activitypub.actors.assert(object.actor);
|
||||
const result = await posts.upvote(localId || id, object.actor);
|
||||
if (localId) {
|
||||
socketHelpers.upvote(result, 'notifications:upvoted-your-post-in');
|
||||
|
||||
@@ -31,12 +31,19 @@ async function unlock(value) {
|
||||
Notes._normalizeTags = async (tag, cid) => {
|
||||
const systemTags = (meta.config.systemTags || '').split(',');
|
||||
const maxTags = await categories.getCategoryField(cid, 'maxTags');
|
||||
const tags = (tag || [])
|
||||
let tags = tag || [];
|
||||
|
||||
if (!Array.isArray(tags)) { // the "|| []" should handle null/undefined values... #famouslastwords
|
||||
tags = [tags];
|
||||
}
|
||||
|
||||
tags = tags
|
||||
.filter(({ type }) => type === 'Hashtag')
|
||||
.map((tag) => {
|
||||
tag.name = tag.name.startsWith('#') ? tag.name.slice(1) : tag.name;
|
||||
return tag;
|
||||
})
|
||||
.filter(o => o.type === 'Hashtag' && !systemTags.includes(o.name))
|
||||
.filter(({ name }) => !systemTags.includes(name))
|
||||
.map(t => t.name);
|
||||
|
||||
if (tags.length > maxTags) {
|
||||
@@ -63,210 +70,218 @@ Notes.assert = async (uid, input, options = { skipChecks: false }) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
id = (await activitypub.checkHeader(id)) || id;
|
||||
|
||||
let chain;
|
||||
let context = await activitypub.contexts.get(uid, id);
|
||||
if (context.tid) {
|
||||
await unlock(id);
|
||||
const { tid } = context;
|
||||
return { tid, count: 0 };
|
||||
} else if (context.context) {
|
||||
chain = Array.from(await activitypub.contexts.getItems(uid, context.context, { input }));
|
||||
if (chain && chain.length) {
|
||||
// Context resolves, use in later topic creation
|
||||
context = context.context;
|
||||
}
|
||||
} else {
|
||||
context = undefined;
|
||||
}
|
||||
|
||||
if (!chain || !chain.length) {
|
||||
// Fall back to inReplyTo traversal on context retrieval failure
|
||||
chain = Array.from(await Notes.getParentChain(uid, input));
|
||||
chain.reverse();
|
||||
}
|
||||
|
||||
// Can't resolve — give up.
|
||||
if (!chain.length) {
|
||||
await unlock(id);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Reorder chain items by timestamp
|
||||
chain = chain.sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
const mainPost = chain[0];
|
||||
let { pid: mainPid, tid, uid: authorId, timestamp, title, content, sourceContent, _activitypub } = mainPost;
|
||||
const hasTid = !!tid;
|
||||
|
||||
const cid = hasTid ? await topics.getTopicField(tid, 'cid') : options.cid || -1;
|
||||
|
||||
if (options.cid && cid === -1) {
|
||||
// Move topic if currently uncategorized
|
||||
await topics.tools.move(tid, { cid: options.cid, uid: 'system' });
|
||||
}
|
||||
|
||||
const members = await db.isSortedSetMembers(`tid:${tid}:posts`, chain.slice(1).map(p => p.pid));
|
||||
members.unshift(await posts.exists(mainPid));
|
||||
if (tid && members.every(Boolean)) {
|
||||
// All cached, return early.
|
||||
activitypub.helpers.log('[notes/assert] No new notes to process.');
|
||||
await unlock(id);
|
||||
return { tid, count: 0 };
|
||||
}
|
||||
|
||||
if (hasTid) {
|
||||
mainPid = await topics.getTopicField(tid, 'mainPid');
|
||||
} else {
|
||||
// Check recipients/audience for category (local or remote)
|
||||
const set = activitypub.helpers.makeSet(_activitypub, ['to', 'cc', 'audience']);
|
||||
await activitypub.actors.assert(Array.from(set));
|
||||
|
||||
// Local
|
||||
const resolved = await Promise.all(Array.from(set).map(async id => await activitypub.helpers.resolveLocalId(id)));
|
||||
const recipientCids = resolved
|
||||
.filter(Boolean)
|
||||
.filter(({ type }) => type === 'category')
|
||||
.map(obj => obj.id);
|
||||
|
||||
// Remote
|
||||
let remoteCid;
|
||||
const assertedGroups = await categories.exists(Array.from(set));
|
||||
try {
|
||||
const { hostname } = new URL(mainPid);
|
||||
remoteCid = Array.from(set).filter((id, idx) => {
|
||||
const { hostname: cidHostname } = new URL(id);
|
||||
return assertedGroups[idx] && cidHostname === hostname;
|
||||
}).shift();
|
||||
} catch (e) {
|
||||
// noop
|
||||
winston.error('[activitypub/notes.assert] Could not parse URL of mainPid', e.stack);
|
||||
try {
|
||||
if (!(options.skipChecks || process.env.hasOwnProperty('CI'))) {
|
||||
id = (await activitypub.checkHeader(id)) || id;
|
||||
}
|
||||
|
||||
if (remoteCid || recipientCids.length) {
|
||||
// Overrides passed-in value, respect addressing from main post over booster
|
||||
options.cid = remoteCid || recipientCids.shift();
|
||||
let chain;
|
||||
let context = await activitypub.contexts.get(uid, id);
|
||||
if (context.tid) {
|
||||
await unlock(id);
|
||||
const { tid } = context;
|
||||
return { tid, count: 0 };
|
||||
} else if (context.context) {
|
||||
chain = Array.from(await activitypub.contexts.getItems(uid, context.context, { input }));
|
||||
if (chain && chain.length) {
|
||||
// Context resolves, use in later topic creation
|
||||
context = context.context;
|
||||
}
|
||||
} else {
|
||||
context = undefined;
|
||||
}
|
||||
|
||||
// Auto-categorization (takes place only if all other categorization efforts fail)
|
||||
if (!options.cid) {
|
||||
options.cid = await assignCategory(mainPost);
|
||||
if (!chain || !chain.length) {
|
||||
// Fall back to inReplyTo traversal on context retrieval failure
|
||||
chain = Array.from(await Notes.getParentChain(uid, input));
|
||||
chain.reverse();
|
||||
}
|
||||
|
||||
// mainPid ok to leave as-is
|
||||
if (!title) {
|
||||
const sentences = tokenizer.sentences(content || sourceContent, { sanitize: true });
|
||||
title = sentences.shift();
|
||||
}
|
||||
|
||||
// Remove any custom emoji from title
|
||||
if (_activitypub && _activitypub.tag && Array.isArray(_activitypub.tag)) {
|
||||
_activitypub.tag
|
||||
.filter(tag => tag.type === 'Emoji')
|
||||
.forEach((tag) => {
|
||||
title = title.replace(new RegExp(tag.name, 'g'), '');
|
||||
});
|
||||
}
|
||||
}
|
||||
mainPid = utils.isNumber(mainPid) ? parseInt(mainPid, 10) : mainPid;
|
||||
|
||||
// Relation & privilege check for local categories
|
||||
const inputIndex = chain.map(n => n.pid).indexOf(id);
|
||||
const hasRelation =
|
||||
uid || hasTid ||
|
||||
options.skipChecks || options.cid ||
|
||||
await assertRelation(chain[inputIndex !== -1 ? inputIndex : 0]);
|
||||
|
||||
const privilege = `topics:${tid ? 'reply' : 'create'}`;
|
||||
const allowed = await privileges.categories.can(privilege, options.cid || cid, activitypub._constants.uid);
|
||||
if (!hasRelation || !allowed) {
|
||||
if (!hasRelation) {
|
||||
activitypub.helpers.log(`[activitypub/notes.assert] Not asserting ${id} as it has no relation to existing tracked content.`);
|
||||
}
|
||||
|
||||
await unlock(id);
|
||||
return null;
|
||||
}
|
||||
|
||||
tid = tid || utils.generateUUID();
|
||||
mainPost.tid = tid;
|
||||
|
||||
const urlMap = chain.reduce((map, post) => (post.url ? map.set(post.url, post.id) : map), new Map());
|
||||
const unprocessed = chain.map((post) => {
|
||||
post.tid = tid; // add tid to post hash
|
||||
|
||||
// Ensure toPids in replies are ids
|
||||
if (urlMap.has(post.toPid)) {
|
||||
post.toPid = urlMap.get(post.toPid);
|
||||
}
|
||||
|
||||
return post;
|
||||
}).filter((p, idx) => !members[idx]);
|
||||
const count = unprocessed.length;
|
||||
activitypub.helpers.log(`[notes/assert] ${count} new note(s) found.`);
|
||||
|
||||
if (!hasTid) {
|
||||
const { to, cc, attachment } = mainPost._activitypub;
|
||||
const tags = await Notes._normalizeTags(mainPost._activitypub.tag || []);
|
||||
|
||||
try {
|
||||
await topics.post({
|
||||
tid,
|
||||
uid: authorId,
|
||||
cid: options.cid || cid,
|
||||
pid: mainPid,
|
||||
title,
|
||||
timestamp,
|
||||
tags,
|
||||
content: mainPost.content,
|
||||
sourceContent: mainPost.sourceContent,
|
||||
_activitypub: mainPost._activitypub,
|
||||
});
|
||||
unprocessed.shift();
|
||||
} catch (e) {
|
||||
activitypub.helpers.log(`[activitypub/notes.assert] Could not post topic (${mainPost.pid}): ${e.message}`);
|
||||
// Can't resolve — give up.
|
||||
if (!chain.length) {
|
||||
await unlock(id);
|
||||
return null;
|
||||
}
|
||||
|
||||
// These must come after topic is posted
|
||||
// Reorder chain items by timestamp
|
||||
chain = chain.sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
const mainPost = chain[0];
|
||||
let { pid: mainPid, tid, uid: authorId, timestamp, title, content, sourceContent, _activitypub } = mainPost;
|
||||
const hasTid = !!tid;
|
||||
|
||||
const cid = hasTid ? await topics.getTopicField(tid, 'cid') : options.cid || -1;
|
||||
|
||||
if (options.cid && cid === -1) {
|
||||
// Move topic if currently uncategorized
|
||||
await topics.tools.move(tid, { cid: options.cid, uid: 'system' });
|
||||
}
|
||||
|
||||
const exists = await posts.exists(chain.map(p => p.pid));
|
||||
if (tid && exists.every(Boolean)) {
|
||||
// All cached, return early.
|
||||
activitypub.helpers.log('[notes/assert] No new notes to process.');
|
||||
await unlock(id);
|
||||
return { tid, count: 0 };
|
||||
}
|
||||
|
||||
if (hasTid) {
|
||||
mainPid = await topics.getTopicField(tid, 'mainPid');
|
||||
} else {
|
||||
// Check recipients/audience for category (local or remote)
|
||||
const set = activitypub.helpers.makeSet(_activitypub, ['to', 'cc', 'audience']);
|
||||
await activitypub.actors.assert(Array.from(set));
|
||||
|
||||
// Local
|
||||
const resolved = await Promise.all(Array.from(set).map(async id => await activitypub.helpers.resolveLocalId(id)));
|
||||
const recipientCids = resolved
|
||||
.filter(Boolean)
|
||||
.filter(({ type }) => type === 'category')
|
||||
.map(obj => obj.id);
|
||||
|
||||
// Remote
|
||||
let remoteCid;
|
||||
const assertedGroups = await categories.exists(Array.from(set));
|
||||
try {
|
||||
const { hostname } = new URL(mainPid);
|
||||
remoteCid = Array.from(set).filter((id, idx) => {
|
||||
const { hostname: cidHostname } = new URL(id);
|
||||
return assertedGroups[idx] && cidHostname === hostname;
|
||||
}).shift();
|
||||
} catch (e) {
|
||||
// noop
|
||||
winston.error('[activitypub/notes.assert] Could not parse URL of mainPid', e.stack);
|
||||
}
|
||||
|
||||
if (remoteCid || recipientCids.length) {
|
||||
// Overrides passed-in value, respect addressing from main post over booster
|
||||
options.cid = remoteCid || recipientCids.shift();
|
||||
}
|
||||
|
||||
// Auto-categorization (takes place only if all other categorization efforts fail)
|
||||
if (!options.cid) {
|
||||
options.cid = await assignCategory(mainPost);
|
||||
}
|
||||
|
||||
// mainPid ok to leave as-is
|
||||
if (!title) {
|
||||
const sentences = tokenizer.sentences(content || sourceContent, { sanitize: true });
|
||||
title = sentences.shift();
|
||||
}
|
||||
|
||||
// Remove any custom emoji from title
|
||||
if (_activitypub && _activitypub.tag && Array.isArray(_activitypub.tag)) {
|
||||
_activitypub.tag
|
||||
.filter(tag => tag.type === 'Emoji')
|
||||
.forEach((tag) => {
|
||||
title = title.replace(new RegExp(tag.name, 'g'), '');
|
||||
});
|
||||
}
|
||||
}
|
||||
mainPid = utils.isNumber(mainPid) ? parseInt(mainPid, 10) : mainPid;
|
||||
|
||||
// Relation & privilege check for local categories
|
||||
const inputIndex = chain.map(n => n.pid).indexOf(id);
|
||||
const hasRelation =
|
||||
uid || hasTid ||
|
||||
options.skipChecks || options.cid ||
|
||||
await assertRelation(chain[inputIndex !== -1 ? inputIndex : 0]);
|
||||
|
||||
const privilege = `topics:${tid ? 'reply' : 'create'}`;
|
||||
const allowed = await privileges.categories.can(privilege, options.cid || cid, activitypub._constants.uid);
|
||||
if (!hasRelation || !allowed) {
|
||||
if (!hasRelation) {
|
||||
activitypub.helpers.log(`[activitypub/notes.assert] Not asserting ${id} as it has no relation to existing tracked content.`);
|
||||
}
|
||||
|
||||
await unlock(id);
|
||||
return null;
|
||||
}
|
||||
|
||||
tid = tid || utils.generateUUID();
|
||||
mainPost.tid = tid;
|
||||
|
||||
const urlMap = chain.reduce((map, post) => (post.url ? map.set(post.url, post.id) : map), new Map());
|
||||
const unprocessed = chain.map((post) => {
|
||||
post.tid = tid; // add tid to post hash
|
||||
|
||||
// Ensure toPids in replies are ids
|
||||
if (urlMap.has(post.toPid)) {
|
||||
post.toPid = urlMap.get(post.toPid);
|
||||
}
|
||||
|
||||
return post;
|
||||
}).filter((p, idx) => !exists[idx]);
|
||||
const count = unprocessed.length;
|
||||
activitypub.helpers.log(`[notes/assert] ${count} new note(s) found.`);
|
||||
|
||||
if (!hasTid) {
|
||||
const { to, cc, attachment } = mainPost._activitypub;
|
||||
const tags = await Notes._normalizeTags(mainPost._activitypub.tag || []);
|
||||
|
||||
try {
|
||||
await topics.post({
|
||||
tid,
|
||||
uid: authorId,
|
||||
cid: options.cid || cid,
|
||||
pid: mainPid,
|
||||
title,
|
||||
timestamp,
|
||||
tags,
|
||||
content: mainPost.content,
|
||||
sourceContent: mainPost.sourceContent,
|
||||
_activitypub: mainPost._activitypub,
|
||||
});
|
||||
unprocessed.shift();
|
||||
} catch (e) {
|
||||
activitypub.helpers.log(`[activitypub/notes.assert] Could not post topic (${mainPost.pid}): ${e.message}`);
|
||||
await unlock(id);
|
||||
return null;
|
||||
}
|
||||
|
||||
// These must come after topic is posted
|
||||
await Promise.all([
|
||||
Notes.updateLocalRecipients(mainPid, { to, cc }),
|
||||
mainPost._activitypub.image ? topics.thumbs.associate({
|
||||
id: tid,
|
||||
path: mainPost._activitypub.image,
|
||||
}) : null,
|
||||
posts.attachments.update(mainPid, attachment),
|
||||
]);
|
||||
|
||||
if (context) {
|
||||
activitypub.helpers.log(`[activitypub/notes.assert] Associating tid ${tid} with context ${context}`);
|
||||
await topics.setTopicField(tid, 'context', context);
|
||||
}
|
||||
}
|
||||
|
||||
for (const post of unprocessed) {
|
||||
const { to, cc, attachment } = post._activitypub;
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await topics.reply(post);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await Promise.all([
|
||||
Notes.updateLocalRecipients(post.pid, { to, cc }),
|
||||
posts.attachments.update(post.pid, attachment),
|
||||
]);
|
||||
} catch (e) {
|
||||
activitypub.helpers.log(`[activitypub/notes.assert] Could not add reply (${post.pid}): ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
Notes.updateLocalRecipients(mainPid, { to, cc }),
|
||||
mainPost._activitypub.image ? topics.thumbs.associate({
|
||||
id: tid,
|
||||
path: mainPost._activitypub.image,
|
||||
}) : null,
|
||||
posts.attachments.update(mainPid, attachment),
|
||||
Notes.syncUserInboxes(tid, uid),
|
||||
unlock(id),
|
||||
]);
|
||||
|
||||
if (context) {
|
||||
activitypub.helpers.log(`[activitypub/notes.assert] Associating tid ${tid} with context ${context}`);
|
||||
await topics.setTopicField(tid, 'context', context);
|
||||
}
|
||||
return { tid, count };
|
||||
} catch (e) {
|
||||
winston.warn(`[activitypub/notes.assert] Could not assert ${id} (${e.message}), releasing lock.`);
|
||||
await unlock(id);
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const post of unprocessed) {
|
||||
const { to, cc, attachment } = post._activitypub;
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await topics.reply(post);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await Promise.all([
|
||||
Notes.updateLocalRecipients(post.pid, { to, cc }),
|
||||
posts.attachments.update(post.pid, attachment),
|
||||
]);
|
||||
} catch (e) {
|
||||
activitypub.helpers.log(`[activitypub/notes.assert] Could not add reply (${post.pid}): ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
Notes.syncUserInboxes(tid, uid),
|
||||
unlock(id),
|
||||
]);
|
||||
|
||||
return { tid, count };
|
||||
};
|
||||
|
||||
Notes.assertPrivate = async (object) => {
|
||||
@@ -399,15 +414,18 @@ async function assertRelation(post) {
|
||||
}
|
||||
|
||||
async function assignCategory(post) {
|
||||
activitypub.helpers.log('[activitypub] Checking auto-categorization rules.');
|
||||
let cid = undefined;
|
||||
const rules = await activitypub.rules.list();
|
||||
const tags = await Notes._normalizeTags(post._activitypub.tag || []);
|
||||
let tags = await Notes._normalizeTags(post._activitypub.tag || []);
|
||||
tags = tags.map(tag => tag.toLowerCase());
|
||||
|
||||
cid = rules.reduce((cid, { type, value, cid: target }) => {
|
||||
if (!cid) {
|
||||
switch (type) {
|
||||
case 'hashtag': {
|
||||
if (tags.includes(value)) {
|
||||
if (tags.includes(value.toLowerCase())) {
|
||||
activitypub.helpers.log(`[activitypub] - Rule match: #${value}; cid: ${target}`);
|
||||
return target;
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -136,6 +136,7 @@ Actors.topic = async function (req, res, next) {
|
||||
let collection;
|
||||
let pids;
|
||||
try {
|
||||
// pids are used in generation of digest only.
|
||||
([collection, pids] = await Promise.all([
|
||||
activitypub.helpers.generateCollection({
|
||||
set: `tid:${req.params.tid}:posts`,
|
||||
@@ -151,7 +152,6 @@ Actors.topic = async function (req, res, next) {
|
||||
}
|
||||
pids.push(mainPid);
|
||||
pids = pids.map(pid => (utils.isNumber(pid) ? `${nconf.get('url')}/post/${pid}` : pid));
|
||||
collection.totalItems += 1; // account for mainPid
|
||||
|
||||
// Generate digest for ETag
|
||||
const digest = activitypub.helpers.generateDigest(new Set(pids));
|
||||
@@ -168,15 +168,18 @@ Actors.topic = async function (req, res, next) {
|
||||
}
|
||||
res.set('ETag', digest);
|
||||
|
||||
// Convert pids to urls
|
||||
// Add OP to collection on first (or only) page
|
||||
if (page || collection.totalItems < perPage) {
|
||||
collection.orderedItems = collection.orderedItems || [];
|
||||
if (!page || page === 1) { // add OP to collection
|
||||
if (!page || page === 1) {
|
||||
collection.orderedItems.unshift(mainPid);
|
||||
collection.totalItems += 1;
|
||||
}
|
||||
collection.orderedItems = collection.orderedItems.map(pid => (utils.isNumber(pid) ? `${nconf.get('url')}/post/${pid}` : pid));
|
||||
}
|
||||
|
||||
// Convert pids to urls
|
||||
collection.orderedItems = collection.orderedItems.map(pid => (utils.isNumber(pid) ? `${nconf.get('url')}/post/${pid}` : pid));
|
||||
|
||||
const object = {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
id: `${nconf.get('url')}/topic/${req.params.tid}${collection.orderedItems && page ? `?page=${page}` : ''}`,
|
||||
|
||||
@@ -22,9 +22,9 @@ recentController.get = async function (req, res, next) {
|
||||
res.render('recent', data);
|
||||
};
|
||||
|
||||
recentController.getData = async function (req, url, sort) {
|
||||
recentController.getData = async function (req, url, sort, selectedTerm = 'alltime') {
|
||||
const page = parseInt(req.query.page, 10) || 1;
|
||||
let term = helpers.terms[req.query.term];
|
||||
let term = helpers.terms[req.query.term || selectedTerm];
|
||||
const { cid, tag } = req.query;
|
||||
const filter = req.query.filter || '';
|
||||
|
||||
@@ -79,6 +79,7 @@ recentController.getData = async function (req, url, sort) {
|
||||
data.selectedTag = tagData.selectedTag;
|
||||
data.selectedTags = tagData.selectedTags;
|
||||
data['feeds:disableRSS'] = meta.config['feeds:disableRSS'] || 0;
|
||||
data['reputation:disabled'] = meta.config['reputation:disabled'];
|
||||
if (!meta.config['feeds:disableRSS']) {
|
||||
data.rssFeedUrl = `${relative_path}/${url}.rss`;
|
||||
if (req.loggedIn) {
|
||||
|
||||
@@ -75,6 +75,7 @@ unreadController.get = async function (req, res) {
|
||||
data.selectedTags = tagData.selectedTags;
|
||||
data.filters = helpers.buildFilters(baseUrl, filter, req.query);
|
||||
data.selectedFilter = data.filters.find(filter => filter && filter.selected);
|
||||
data['reputation:disabled'] = meta.config['reputation:disabled'];
|
||||
|
||||
res.render('unread', data);
|
||||
};
|
||||
|
||||
@@ -85,7 +85,8 @@ module.exports = function (module) {
|
||||
text: `
|
||||
SELECT o."_key"
|
||||
FROM "legacy_object_live" o
|
||||
WHERE o."_key" LIKE '${match}'`,
|
||||
WHERE o."_key" LIKE $1`,
|
||||
values: [match],
|
||||
});
|
||||
|
||||
return res.rows.map(r => r._key);
|
||||
|
||||
@@ -69,10 +69,6 @@ connection.connect = async function (options) {
|
||||
}).catch((err) => {
|
||||
winston.error('Error connecting to Redis:', err);
|
||||
});
|
||||
|
||||
if (options.password) {
|
||||
cxn.auth(options.password);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@ actions.buildCSS = async function buildCSS(data) {
|
||||
};
|
||||
if (data.minify) {
|
||||
opts.silenceDeprecations = [
|
||||
'legacy-js-api', 'mixed-decls', 'color-functions',
|
||||
'legacy-js-api', 'color-functions',
|
||||
'global-builtin', 'import',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -145,12 +145,18 @@ middleware.logApiUsage = async function logApiUsage(req, res, next) {
|
||||
};
|
||||
|
||||
middleware.routeTouchIcon = function routeTouchIcon(req, res) {
|
||||
if (meta.config['brand:touchIcon'] && validator.isURL(meta.config['brand:touchIcon'])) {
|
||||
return res.redirect(meta.config['brand:touchIcon']);
|
||||
const brandTouchIcon = meta.config['brand:touchIcon'];
|
||||
if (brandTouchIcon && validator.isURL(brandTouchIcon)) {
|
||||
return res.redirect(brandTouchIcon);
|
||||
}
|
||||
|
||||
let iconPath = '';
|
||||
if (meta.config['brand:touchIcon']) {
|
||||
iconPath = path.join(nconf.get('upload_path'), meta.config['brand:touchIcon'].replace(/assets\/uploads/, ''));
|
||||
if (brandTouchIcon) {
|
||||
const uploadPath = nconf.get('upload_path');
|
||||
iconPath = path.join(uploadPath, brandTouchIcon.replace(/assets\/uploads/, ''));
|
||||
if (!iconPath.startsWith(uploadPath)) {
|
||||
return res.status(404).send('Not found');
|
||||
}
|
||||
} else {
|
||||
iconPath = path.join(nconf.get('base_dir'), 'public/images/touch/512.png');
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ const _ = require('lodash');
|
||||
|
||||
const db = require('./database');
|
||||
const User = require('./user');
|
||||
const categories = require('./categories');
|
||||
const posts = require('./posts');
|
||||
const groups = require('./groups');
|
||||
const meta = require('./meta');
|
||||
@@ -84,7 +85,24 @@ Notifications.getMultiple = async function (nids) {
|
||||
const notifications = await db.getObjects(keys);
|
||||
|
||||
const userKeys = notifications.map(n => n && n.from);
|
||||
const usersData = await User.getUsersFields(userKeys, ['username', 'userslug', 'picture']);
|
||||
let [usersData, categoriesData] = await Promise.all([
|
||||
User.getUsersFields(userKeys, ['username', 'userslug', 'picture']),
|
||||
categories.getCategoriesFields(userKeys, ['cid', 'name', 'slug', 'picture']),
|
||||
]);
|
||||
// Merge valid categoriesData into usersData
|
||||
usersData = usersData.map((userData, idx) => {
|
||||
const categoryData = categoriesData[idx];
|
||||
if (!userData.uid && categoryData.cid) {
|
||||
return {
|
||||
username: categoryData.slug,
|
||||
displayname: categoryData.name,
|
||||
userslug: categoryData.slug,
|
||||
picture: categoryData.picture,
|
||||
};
|
||||
}
|
||||
|
||||
return userData;
|
||||
});
|
||||
|
||||
notifications.forEach((notification, index) => {
|
||||
if (notification) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
const winston = require('winston');
|
||||
|
||||
const meta = require('../../meta');
|
||||
const userDigest = require('../../user/digest');
|
||||
const userEmail = require('../../user/email');
|
||||
@@ -14,55 +16,59 @@ Email.test = async function (socket, data) {
|
||||
...(data.payload || {}),
|
||||
subject: '[[email:test-email.subject]]',
|
||||
};
|
||||
try {
|
||||
switch (data.template) {
|
||||
case 'digest':
|
||||
await userDigest.execute({
|
||||
interval: 'month',
|
||||
subscribers: [socket.uid],
|
||||
});
|
||||
break;
|
||||
|
||||
switch (data.template) {
|
||||
case 'digest':
|
||||
await userDigest.execute({
|
||||
interval: 'month',
|
||||
subscribers: [socket.uid],
|
||||
});
|
||||
break;
|
||||
case 'banned':
|
||||
Object.assign(payload, {
|
||||
username: 'test-user',
|
||||
until: utils.toISOString(Date.now()),
|
||||
reason: 'Test Reason',
|
||||
});
|
||||
await emailer.send(data.template, socket.uid, payload);
|
||||
break;
|
||||
|
||||
case 'banned':
|
||||
Object.assign(payload, {
|
||||
username: 'test-user',
|
||||
until: utils.toISOString(Date.now()),
|
||||
reason: 'Test Reason',
|
||||
});
|
||||
await emailer.send(data.template, socket.uid, payload);
|
||||
break;
|
||||
case 'verify-email':
|
||||
case 'welcome':
|
||||
await userEmail.sendValidationEmail(socket.uid, {
|
||||
force: 1,
|
||||
template: data.template,
|
||||
subject: data.template === 'welcome' ? `[[email:welcome-to, ${meta.config.title || meta.config.browserTitle || 'NodeBB'}]]` : undefined,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'verify-email':
|
||||
case 'welcome':
|
||||
await userEmail.sendValidationEmail(socket.uid, {
|
||||
force: 1,
|
||||
template: data.template,
|
||||
subject: data.template === 'welcome' ? `[[email:welcome-to, ${meta.config.title || meta.config.browserTitle || 'NodeBB'}]]` : undefined,
|
||||
});
|
||||
break;
|
||||
case 'notification': {
|
||||
const notification = await notifications.create({
|
||||
type: 'test',
|
||||
bodyShort: '[[email:notif.test.short]]',
|
||||
bodyLong: '[[email:notif.test.long]]',
|
||||
nid: `uid:${socket.uid}:test`,
|
||||
path: '/',
|
||||
from: socket.uid,
|
||||
});
|
||||
await emailer.send('notification', socket.uid, {
|
||||
path: notification.path,
|
||||
subject: utils.stripHTMLTags(notification.subject || '[[notifications:new-notification]]'),
|
||||
intro: utils.stripHTMLTags(notification.bodyShort),
|
||||
body: notification.bodyLong || '',
|
||||
notification,
|
||||
showUnsubscribe: true,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'notification': {
|
||||
const notification = await notifications.create({
|
||||
type: 'test',
|
||||
bodyShort: '[[email:notif.test.short]]',
|
||||
bodyLong: '[[email:notif.test.long]]',
|
||||
nid: `uid:${socket.uid}:test`,
|
||||
path: '/',
|
||||
from: socket.uid,
|
||||
});
|
||||
await emailer.send('notification', socket.uid, {
|
||||
path: notification.path,
|
||||
subject: utils.stripHTMLTags(notification.subject || '[[notifications:new-notification]]'),
|
||||
intro: utils.stripHTMLTags(notification.bodyShort),
|
||||
body: notification.bodyLong || '',
|
||||
notification,
|
||||
showUnsubscribe: true,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
await emailer.send(data.template, socket.uid, payload);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
await emailer.send(data.template, socket.uid, payload);
|
||||
break;
|
||||
} catch (err) {
|
||||
winston.error(err.stack);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -94,7 +94,10 @@ SocketHelpers.sendNotificationToPostOwner = async function (pid, fromuid, comman
|
||||
return;
|
||||
}
|
||||
fromuid = utils.isNumber(fromuid) ? parseInt(fromuid, 10) : fromuid;
|
||||
const postData = await posts.getPostFields(pid, ['tid', 'uid', 'content']);
|
||||
const [postData, fromCategory] = await Promise.all([
|
||||
posts.getPostFields(pid, ['tid', 'uid', 'content']),
|
||||
!utils.isNumber(fromuid) && categories.exists(fromuid),
|
||||
]);
|
||||
const [canRead, isIgnoring] = await Promise.all([
|
||||
privileges.posts.can('topics:read', pid, postData.uid),
|
||||
topics.isIgnoring([postData.tid], postData.uid),
|
||||
@@ -103,19 +106,17 @@ SocketHelpers.sendNotificationToPostOwner = async function (pid, fromuid, comman
|
||||
return;
|
||||
}
|
||||
const [userData, topicTitle, postObj] = await Promise.all([
|
||||
user.getUserFields(fromuid, ['username']),
|
||||
fromCategory ? categories.getCategoryFields(fromuid, ['name']) : user.getUserFields(fromuid, ['username']),
|
||||
topics.getTopicField(postData.tid, 'title'),
|
||||
posts.parsePost(postData),
|
||||
]);
|
||||
|
||||
const { displayname } = userData;
|
||||
|
||||
const title = utils.decodeHTMLEntities(topicTitle);
|
||||
const titleEscaped = title.replace(/%/g, '%').replace(/,/g, ',');
|
||||
|
||||
const notifObj = await notifications.create({
|
||||
type: command,
|
||||
bodyShort: `[[${notification}, ${displayname}, ${titleEscaped}]]`,
|
||||
bodyShort: `[[${notification}, ${userData.displayname || userData.name}, ${titleEscaped}]]`,
|
||||
bodyLong: postObj.content,
|
||||
pid: pid,
|
||||
tid: postData.tid,
|
||||
|
||||
@@ -11,6 +11,7 @@ module.exports = {
|
||||
method: async function () {
|
||||
const { progress } = this;
|
||||
const cids = await db.getSortedSetRange('categories:cid', 0, -1);
|
||||
progress.total = cids.length;
|
||||
for (const cid of cids) {
|
||||
const uids = await db.getSortedSetRange(`group:cid:${cid}:privileges:moderate:members`, 0, -1);
|
||||
for (const uid of uids) {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
|
||||
'use strict';
|
||||
|
||||
const db = require('../../database');
|
||||
@@ -13,18 +11,24 @@ module.exports = {
|
||||
const { progress } = this;
|
||||
|
||||
const topics = require('../../topics');
|
||||
progress.total = await db.sortedSetCard('topics:tid');
|
||||
await batch.processSortedSet('topics:tid', async (tids) => {
|
||||
for (const tid of tids) {
|
||||
progress.incr();
|
||||
const topicData = await db.getObjectFields(`topic:${tid}`, ['cid', 'pinned', 'postcount']);
|
||||
if (parseInt(topicData.pinned, 10) !== 1) {
|
||||
progress.incr(tids.length);
|
||||
const topicData = await db.getObjectFields(
|
||||
tids.map(tid => `topic:${tid}`),
|
||||
['tid', 'cid', 'pinned', 'postcount'],
|
||||
);
|
||||
const bulkAdd = [];
|
||||
topicData.forEach((topic) => {
|
||||
if (topic && parseInt(topic.pinned, 10) !== 1) {
|
||||
topicData.postcount = parseInt(topicData.postcount, 10) || 0;
|
||||
await db.sortedSetAdd(`cid:${topicData.cid}:tids:posts`, topicData.postcount, tid);
|
||||
bulkAdd.push([`cid:${topicData.cid}:tids:posts`, topicData.postcount, topicData.tid]);
|
||||
}
|
||||
await topics.updateLastPostTimeFromLastPid(tid);
|
||||
}
|
||||
});
|
||||
await db.sortedSetAddBulk(bulkAdd);
|
||||
await Promise.all(tids.map(tid => topics.updateLastPostTimeFromLastPid(tid)));
|
||||
}, {
|
||||
progress: progress,
|
||||
batch: 500,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -11,14 +11,23 @@ module.exports = {
|
||||
method: async function () {
|
||||
const { progress } = this;
|
||||
|
||||
progress.total = await db.sortedSetCard('users:joindate');
|
||||
|
||||
await batch.processSortedSet('users:joindate', async (uids) => {
|
||||
for (const uid of uids) {
|
||||
progress.incr();
|
||||
const [bans, reasons, userData] = await Promise.all([
|
||||
db.getSortedSetRevRangeWithScores(`uid:${uid}:bans`, 0, -1),
|
||||
db.getSortedSetRevRangeWithScores(`banned:${uid}:reasons`, 0, -1),
|
||||
db.getObjectFields(`user:${uid}`, ['banned', 'banned:expire', 'joindate', 'lastposttime', 'lastonline']),
|
||||
]);
|
||||
progress.incr(uids.length);
|
||||
const [allUserData, allBans] = await Promise.all([
|
||||
db.getObjectsFields(
|
||||
uids.map(uid => `user:${uid}`),
|
||||
['banned', 'banned:expire', 'joindate', 'lastposttime', 'lastonline'],
|
||||
),
|
||||
db.getSortedSetsMembersWithScores(
|
||||
uids.map(uid => `uid:${uid}:bans`)
|
||||
),
|
||||
]);
|
||||
|
||||
await Promise.all(uids.map(async (uid, index) => {
|
||||
const userData = allUserData[index];
|
||||
const bans = allBans[index] || [];
|
||||
|
||||
// has no history, but is banned, create plain object with just uid and timestmap
|
||||
if (!bans.length && parseInt(userData.banned, 10)) {
|
||||
@@ -31,6 +40,7 @@ module.exports = {
|
||||
const banKey = `uid:${uid}:ban:${banTimestamp}`;
|
||||
await addBan(uid, banKey, { uid: uid, timestamp: banTimestamp });
|
||||
} else if (bans.length) {
|
||||
const reasons = await db.getSortedSetRevRangeWithScores(`banned:${uid}:reasons`, 0, -1);
|
||||
// process ban history
|
||||
for (const ban of bans) {
|
||||
const reasonData = reasons.find(reasonData => reasonData.score === ban.score);
|
||||
@@ -46,14 +56,16 @@ module.exports = {
|
||||
await addBan(uid, banKey, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
}, {
|
||||
progress: this.progress,
|
||||
batch: 500,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
async function addBan(uid, key, data) {
|
||||
await db.setObject(key, data);
|
||||
await db.sortedSetAdd(`uid:${uid}:bans:timestamp`, data.timestamp, key);
|
||||
await Promise.all([
|
||||
db.setObject(key, data),
|
||||
db.sortedSetAdd(`uid:${uid}:bans:timestamp`, data.timestamp, key),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -11,27 +11,34 @@ module.exports = {
|
||||
method: async function () {
|
||||
const { progress } = this;
|
||||
|
||||
await batch.processSortedSet('users:joindate', async (uids) => {
|
||||
async function updateHistory(uid, set, fieldName) {
|
||||
const count = await db.sortedSetCard(set);
|
||||
if (count <= 0) {
|
||||
// User has not changed their username/email before, record original username
|
||||
const userData = await user.getUserFields(uid, [fieldName, 'joindate']);
|
||||
if (userData && userData.joindate && userData[fieldName]) {
|
||||
await db.sortedSetAdd(set, userData.joindate, [userData[fieldName], userData.joindate].join(':'));
|
||||
}
|
||||
}
|
||||
}
|
||||
progress.total = await db.sortedSetCard('users:joindate');
|
||||
|
||||
await Promise.all(uids.map(async (uid) => {
|
||||
await Promise.all([
|
||||
updateHistory(uid, `user:${uid}:usernames`, 'username'),
|
||||
updateHistory(uid, `user:${uid}:emails`, 'email'),
|
||||
]);
|
||||
progress.incr();
|
||||
}));
|
||||
await batch.processSortedSet('users:joindate', async (uids) => {
|
||||
const [usernameHistory, emailHistory, userData] = await Promise.all([
|
||||
db.sortedSetsCard(uids.map(uid => `user:${uid}:usernames`)),
|
||||
db.sortedSetsCard(uids.map(uid => `user:${uid}:emails`)),
|
||||
user.getUsersFields(uids, ['uid', 'username', 'email', 'joindate']),
|
||||
]);
|
||||
|
||||
const bulkAdd = [];
|
||||
userData.forEach((data, index) => {
|
||||
const thisUsernameHistory = usernameHistory[index];
|
||||
const thisEmailHistory = emailHistory[index];
|
||||
if (thisUsernameHistory <= 0 && data && data.joindate && data.username) {
|
||||
bulkAdd.push([
|
||||
`user:${data.uid}:usernames`, data.joindate, [data.username, data.joindate].join(':'),
|
||||
]);
|
||||
}
|
||||
if (thisEmailHistory <= 0 && data && data.joindate && data.email) {
|
||||
bulkAdd.push([
|
||||
`user:${data.uid}:emails`, data.joindate, [data.email, data.joindate].join(':'),
|
||||
]);
|
||||
}
|
||||
});
|
||||
await db.sortedSetAddBulk(bulkAdd);
|
||||
progress.incr(uids.length);
|
||||
}, {
|
||||
progress: this.progress,
|
||||
batch: 500,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,45 +1,32 @@
|
||||
'use strict';
|
||||
|
||||
const async = require('async');
|
||||
|
||||
const db = require('../../database');
|
||||
const user = require('../../user');
|
||||
const batch = require('../../batch');
|
||||
|
||||
module.exports = {
|
||||
name: 'Delete username email history for deleted users',
|
||||
timestamp: Date.UTC(2019, 2, 25),
|
||||
method: function (callback) {
|
||||
method: async function () {
|
||||
const { progress } = this;
|
||||
let currentUid = 1;
|
||||
db.getObjectField('global', 'nextUid', (err, nextUid) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
progress.total = nextUid;
|
||||
async.whilst((next) => {
|
||||
next(null, currentUid < nextUid);
|
||||
},
|
||||
(next) => {
|
||||
progress.incr();
|
||||
user.exists(currentUid, (err, exists) => {
|
||||
if (err) {
|
||||
return next(err);
|
||||
}
|
||||
if (exists) {
|
||||
currentUid += 1;
|
||||
return next();
|
||||
}
|
||||
db.deleteAll([`user:${currentUid}:usernames`, `user:${currentUid}:emails`], (err) => {
|
||||
if (err) {
|
||||
return next(err);
|
||||
}
|
||||
currentUid += 1;
|
||||
next();
|
||||
});
|
||||
});
|
||||
},
|
||||
(err) => {
|
||||
callback(err);
|
||||
});
|
||||
|
||||
progress.total = await db.getObjectField('global', 'nextUid');
|
||||
const allUids = [];
|
||||
for (let i = 1; i < progress.total; i += 1) {
|
||||
allUids.push(i);
|
||||
}
|
||||
await batch.processArray(allUids, async (uids) => {
|
||||
const exists = await user.exists(uids);
|
||||
const missingUids = uids.filter((uid, index) => !exists[index]);
|
||||
const keysToDelete = [
|
||||
...missingUids.map(uid => `user:${uid}:usernames`),
|
||||
...missingUids.map(uid => `user:${uid}:emails`),
|
||||
];
|
||||
await db.deleteAll(keysToDelete);
|
||||
progress.incr(uids.length);
|
||||
}, {
|
||||
batch: 500,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -12,10 +12,12 @@ module.exports = {
|
||||
const { progress } = this;
|
||||
|
||||
await batch.processSortedSet('users:joindate', async (uids) => {
|
||||
await Promise.all(uids.map(async (uid) => {
|
||||
progress.incr();
|
||||
|
||||
const notes = await db.getSortedSetRevRange(`uid:${uid}:moderation:notes`, 0, -1);
|
||||
progress.incr(uids.length);
|
||||
const allNotes = await db.getSortedSetsMembers(
|
||||
uids.map(uid => `uid:${uid}:moderation:notes`)
|
||||
);
|
||||
await Promise.all(uids.map(async (uid, index) => {
|
||||
const notes = allNotes[index];
|
||||
for (const note of notes) {
|
||||
const noteData = JSON.parse(note);
|
||||
noteData.timestamp = noteData.timestamp || Date.now();
|
||||
|
||||
@@ -8,6 +8,7 @@ module.exports = {
|
||||
timestamp: Date.UTC(2019, 9, 7),
|
||||
method: async function () {
|
||||
const { progress } = this;
|
||||
progress.total = await db.sortedSetCard('posts:pid') + await db.sortedSetCard('topics:tid');
|
||||
await cleanPost(progress);
|
||||
await cleanTopic(progress);
|
||||
},
|
||||
@@ -51,7 +52,6 @@ async function cleanPost(progress) {
|
||||
}));
|
||||
}, {
|
||||
batch: 500,
|
||||
progress: progress,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -90,6 +90,5 @@ async function cleanTopic(progress) {
|
||||
}));
|
||||
}, {
|
||||
batch: 500,
|
||||
progress: progress,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,29 +1,30 @@
|
||||
'use strict';
|
||||
|
||||
const async = require('async');
|
||||
|
||||
const db = require('../../database');
|
||||
const batch = require('../../batch');
|
||||
|
||||
module.exports = {
|
||||
name: 'New sorted set cid:<cid>:tids:lastposttime',
|
||||
timestamp: Date.UTC(2017, 9, 30),
|
||||
method: function (callback) {
|
||||
method: async function () {
|
||||
const { progress } = this;
|
||||
progress.total = await db.sortedSetCard('topics:tid');
|
||||
|
||||
require('../../batch').processSortedSet('topics:tid', (tids, next) => {
|
||||
async.eachSeries(tids, (tid, next) => {
|
||||
db.getObjectFields(`topic:${tid}`, ['cid', 'timestamp', 'lastposttime'], (err, topicData) => {
|
||||
if (err || !topicData) {
|
||||
return next(err);
|
||||
}
|
||||
progress.incr();
|
||||
|
||||
const timestamp = topicData.lastposttime || topicData.timestamp || Date.now();
|
||||
db.sortedSetAdd(`cid:${topicData.cid}:tids:lastposttime`, timestamp, tid, next);
|
||||
}, next);
|
||||
}, next);
|
||||
await batch.processSortedSet('topics:tid', async (tids) => {
|
||||
const topicData = await db.getObjectsFields(
|
||||
tids.map(tid => `topic:${tid}`), ['tid', 'cid', 'timestamp', 'lastposttime']
|
||||
);
|
||||
const bulkAdd = [];
|
||||
topicData.forEach((data) => {
|
||||
if (data && data.cid && data.tid) {
|
||||
const timestamp = data.lastposttime || data.timestamp || Date.now();
|
||||
bulkAdd.push([`cid:${data.cid}:tids:lastposttime`, timestamp, data.tid]);
|
||||
}
|
||||
});
|
||||
await db.sortedSetAddBulk(bulkAdd);
|
||||
progress.incr(tids.length);
|
||||
}, {
|
||||
progress: this.progress,
|
||||
}, callback);
|
||||
batch: 500,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -8,23 +8,38 @@ module.exports = {
|
||||
timestamp: Date.UTC(2017, 10, 15),
|
||||
method: async function () {
|
||||
const { progress } = this;
|
||||
|
||||
progress.total = await db.sortedSetCard('users:joindate');
|
||||
await batch.processSortedSet('users:joindate', async (uids) => {
|
||||
await Promise.all(uids.map(async (uid) => {
|
||||
progress.incr();
|
||||
const userSettings = await db.getObjectFields(`user:${uid}:settings`, ['sendChatNotifications', 'sendPostNotifications']);
|
||||
if (userSettings) {
|
||||
|
||||
const userSettings = await db.getObjectsFields(
|
||||
uids.map(uid => `user:${uid}:settings`),
|
||||
['sendChatNotifications', 'sendPostNotifications'],
|
||||
);
|
||||
|
||||
const bulkSet = [];
|
||||
userSettings.forEach((settings, index) => {
|
||||
const set = {};
|
||||
if (settings) {
|
||||
if (parseInt(userSettings.sendChatNotifications, 10) === 1) {
|
||||
await db.setObjectField(`user:${uid}:settings`, 'notificationType_new-chat', 'notificationemail');
|
||||
set['notificationType_new-chat'] = 'notificationemail';
|
||||
}
|
||||
if (parseInt(userSettings.sendPostNotifications, 10) === 1) {
|
||||
await db.setObjectField(`user:${uid}:settings`, 'notificationType_new-reply', 'notificationemail');
|
||||
set['notificationType_new-reply'] = 'notificationemail';
|
||||
}
|
||||
if (Object.keys(set).length) {
|
||||
bulkSet.push([`user:${uids[index]}:settings`, set]);
|
||||
}
|
||||
}
|
||||
await db.deleteObjectFields(`user:${uid}:settings`, ['sendChatNotifications', 'sendPostNotifications']);
|
||||
}));
|
||||
});
|
||||
await db.setObjectBulk(bulkSet);
|
||||
|
||||
await db.deleteObjectFields(
|
||||
uids.map(uid => `user:${uid}:settings`),
|
||||
['sendChatNotifications', 'sendPostNotifications'],
|
||||
);
|
||||
|
||||
progress.incr(uids.length);
|
||||
}, {
|
||||
progress: progress,
|
||||
batch: 500,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -10,32 +10,42 @@ module.exports = {
|
||||
method: async function () {
|
||||
const { progress } = this;
|
||||
|
||||
batch.processSortedSet('topics:tid', async (tids) => {
|
||||
await Promise.all(tids.map(async (tid) => {
|
||||
progress.incr();
|
||||
const topicData = await db.getObjectFields(`topic:${tid}`, ['mainPid', 'cid', 'pinned']);
|
||||
if (topicData.mainPid && topicData.cid) {
|
||||
const postData = await db.getObject(`post:${topicData.mainPid}`);
|
||||
if (postData) {
|
||||
const upvotes = parseInt(postData.upvotes, 10) || 0;
|
||||
const downvotes = parseInt(postData.downvotes, 10) || 0;
|
||||
const data = {
|
||||
upvotes: upvotes,
|
||||
downvotes: downvotes,
|
||||
};
|
||||
const votes = upvotes - downvotes;
|
||||
await Promise.all([
|
||||
db.setObject(`topic:${tid}`, data),
|
||||
db.sortedSetAdd('topics:votes', votes, tid),
|
||||
]);
|
||||
if (parseInt(topicData.pinned, 10) !== 1) {
|
||||
await db.sortedSetAdd(`cid:${topicData.cid}:tids:votes`, votes, tid);
|
||||
}
|
||||
progress.total = await db.sortedSetCard('topics:tid');
|
||||
|
||||
await batch.processSortedSet('topics:tid', async (tids) => {
|
||||
const topicsData = await db.getObjectsFields(
|
||||
tids.map(tid => `topic:${tid}`),
|
||||
['tid', 'mainPid', 'cid', 'pinned'],
|
||||
);
|
||||
const mainPids = topicsData.map(topicData => topicData && topicData.mainPid);
|
||||
const mainPosts = await db.getObjects(mainPids.map(pid => `post:${pid}`));
|
||||
|
||||
const bulkSet = [];
|
||||
const bulkAdd = [];
|
||||
|
||||
topicsData.forEach((topicData, index) => {
|
||||
const mainPost = mainPosts[index];
|
||||
if (mainPost && topicData && topicData.cid) {
|
||||
const upvotes = parseInt(mainPost.upvotes, 10) || 0;
|
||||
const downvotes = parseInt(mainPost.downvotes, 10) || 0;
|
||||
const data = {
|
||||
upvotes: upvotes,
|
||||
downvotes: downvotes,
|
||||
};
|
||||
const votes = upvotes - downvotes;
|
||||
bulkSet.push([`topic:${topicData.tid}`, data]);
|
||||
bulkAdd.push(['topics:votes', votes, topicData.tid]);
|
||||
if (parseInt(topicData.pinned, 10) !== 1) {
|
||||
bulkAdd.push([`cid:${topicData.cid}:tids:votes`, votes, topicData.tid]);
|
||||
}
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
await db.setObjectBulk(bulkSet);
|
||||
await db.sortedSetAddBulk('topics:votes', bulkAdd);
|
||||
|
||||
progress.incr(tids.length);
|
||||
}, {
|
||||
progress: progress,
|
||||
batch: 500,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,57 +1,40 @@
|
||||
'use strict';
|
||||
|
||||
const async = require('async');
|
||||
const db = require('../../database');
|
||||
const batch = require('../../batch');
|
||||
|
||||
|
||||
module.exports = {
|
||||
name: 'Reformatting post diffs to be stored in lists and hash instead of single zset',
|
||||
timestamp: Date.UTC(2018, 2, 15),
|
||||
method: function (callback) {
|
||||
method: async function () {
|
||||
const { progress } = this;
|
||||
|
||||
batch.processSortedSet('posts:pid', (pids, next) => {
|
||||
async.each(pids, (pid, next) => {
|
||||
db.getSortedSetRangeWithScores(`post:${pid}:diffs`, 0, -1, (err, diffs) => {
|
||||
if (err) {
|
||||
return next(err);
|
||||
}
|
||||
progress.total = await db.sortedSetCard('posts:pid');
|
||||
|
||||
if (!diffs || !diffs.length) {
|
||||
progress.incr();
|
||||
return next();
|
||||
}
|
||||
await batch.processSortedSet('posts:pid', async (pids) => {
|
||||
const postDiffs = await db.getSortedSetsMembersWithScores(
|
||||
pids.map(pid => `post:${pid}:diffs`),
|
||||
);
|
||||
|
||||
// For each diff, push to list
|
||||
async.each(diffs, (diff, next) => {
|
||||
async.series([
|
||||
async.apply(db.delete.bind(db), `post:${pid}:diffs`),
|
||||
async.apply(db.listPrepend.bind(db), `post:${pid}:diffs`, diff.score),
|
||||
async.apply(db.setObject.bind(db), `diff:${pid}.${diff.score}`, {
|
||||
pid: pid,
|
||||
patch: diff.value,
|
||||
}),
|
||||
], next);
|
||||
}, (err) => {
|
||||
if (err) {
|
||||
return next(err);
|
||||
}
|
||||
await db.deleteAll(pids.map(pid => `post:${pid}:diffs`));
|
||||
|
||||
progress.incr();
|
||||
return next();
|
||||
});
|
||||
});
|
||||
}, (err) => {
|
||||
if (err) {
|
||||
// Probably type error, ok to incr and continue
|
||||
progress.incr();
|
||||
await Promise.all(postDiffs.map(async (diffs, index) => {
|
||||
if (!diffs || !diffs.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
return next();
|
||||
});
|
||||
diffs.reverse();
|
||||
const pid = pids[index];
|
||||
await db.listAppend(`post:${pid}:diffs`, diffs.map(d => d.score));
|
||||
await db.setObjectBulk(
|
||||
diffs.map(d => ([`diff:${pid}.${d.score}`, {
|
||||
pid: pid,
|
||||
patch: d.value,
|
||||
}]))
|
||||
);
|
||||
}));
|
||||
progress.incr(pids.length);
|
||||
}, {
|
||||
progress: progress,
|
||||
}, callback);
|
||||
batch: 500,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
'use strict';
|
||||
|
||||
const async = require('async');
|
||||
const db = require('../../database');
|
||||
const posts = require('../../posts');
|
||||
const batch = require('../../batch');
|
||||
|
||||
module.exports = {
|
||||
name: 'Refresh post-upload associations',
|
||||
timestamp: Date.UTC(2018, 3, 16),
|
||||
method: function (callback) {
|
||||
method: async function () {
|
||||
const { progress } = this;
|
||||
|
||||
require('../../batch').processSortedSet('posts:pid', (pids, next) => {
|
||||
async.each(pids, (pid, next) => {
|
||||
posts.uploads.sync(pid, next);
|
||||
progress.incr();
|
||||
}, next);
|
||||
progress.total = await db.sortedSetCard('posts:pid');
|
||||
await batch.processSortedSet('posts:pid', async (pids) => {
|
||||
await Promise.all(pids.map(pid => posts.uploads.sync(pid)));
|
||||
progress.incr(pids.length);
|
||||
}, {
|
||||
progress: this.progress,
|
||||
}, callback);
|
||||
batch: 500,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -26,7 +26,7 @@ module.exports = {
|
||||
}
|
||||
|
||||
// user has email but doesn't match whats stored in user hash, gh#11259
|
||||
if (userData.email && userData.email.toLowerCase() !== email.toLowerCase()) {
|
||||
if (userData.email && email && String(userData.email).toLowerCase() !== email.toLowerCase()) {
|
||||
bulkRemove.push(['email:uid', email]);
|
||||
bulkRemove.push(['email:sorted', `${email.toLowerCase()}:${uid}`]);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ module.exports = {
|
||||
method: async function () {
|
||||
const { progress } = this;
|
||||
const nextCid = await db.getObjectField('global', 'nextCid');
|
||||
progress.total = nextCid;
|
||||
const allCids = [];
|
||||
for (let i = 1; i <= nextCid; i++) {
|
||||
allCids.push(i);
|
||||
@@ -18,7 +19,6 @@ module.exports = {
|
||||
progress.incr(cids.length);
|
||||
}, {
|
||||
batch: 500,
|
||||
progress,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -46,6 +46,16 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="cid-{category.cid}-topic-template">
|
||||
[[admin/manage/categories:topic-template]]
|
||||
</label>
|
||||
<textarea id="cid-{category.cid}-topic-template" data-name="topicTemplate" class="form-control" rows="4" />{category.topicTemplate}</textarea>
|
||||
<p class="form-text">
|
||||
[[admin/manage/categories:topic-template.help]]
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 d-flex justify-content-between align-items-center gap-2">
|
||||
<label class="form-label" for="cid-{category.cid}-parentCid">[[admin/manage/categories:parent-category]]</label>
|
||||
<div id="parent-category-selector">
|
||||
|
||||
@@ -227,6 +227,9 @@ function setupHelmet(app) {
|
||||
function setupFavicon(app) {
|
||||
let faviconPath = meta.config['brand:favicon'] || 'favicon.ico';
|
||||
faviconPath = path.join(nconf.get('base_dir'), 'public', faviconPath.replace(/assets\/uploads/, 'uploads'));
|
||||
if (!faviconPath.startsWith(nconf.get('upload_path'))) {
|
||||
faviconPath = path.join(nconf.get('base_dir'), 'public', 'favicon.ico');
|
||||
}
|
||||
if (file.existsSync(faviconPath)) {
|
||||
app.use(nconf.get('relative_path'), favicon(faviconPath));
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const util = require('util');
|
||||
|
||||
const request = require('../src/request');
|
||||
const db = require('./mocks/databasemock');
|
||||
const request = require('../src/request');
|
||||
const api = require('../src/api');
|
||||
const categories = require('../src/categories');
|
||||
const topics = require('../src/topics');
|
||||
@@ -692,6 +692,16 @@ describe('Controllers', () => {
|
||||
assert(body);
|
||||
});
|
||||
|
||||
it('should 404 if brand:touchIcon is not valid', async () => {
|
||||
const oldValue = meta.config['brand:touchIcon'];
|
||||
meta.config['brand:touchIcon'] = '../../not/valid';
|
||||
|
||||
const { response, body } = await request.get(`${nconf.get('url')}/apple-touch-icon`);
|
||||
assert.strictEqual(response.statusCode, 404);
|
||||
assert.strictEqual(body, 'Not found');
|
||||
meta.config['brand:touchIcon'] = oldValue;
|
||||
});
|
||||
|
||||
|
||||
it('should error if guests do not have search privilege', async () => {
|
||||
const { response, body } = await request.get(`${nconf.get('url')}/api/users?query=bar§ion=sort-posts`);
|
||||
|
||||
Reference in New Issue
Block a user