diff --git a/CHANGELOG.md b/CHANGELOG.md index 273e8de4c5..33cb6e494a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,133 @@ +#### v4.1.0 (2025-02-27) + +##### Chores + +* incrementing version number - v4.0.6 (4a52fb2e) +* update changelog for v4.0.6 (78bbea30) +* comment out testing helper call (bad0a4c2) +* incrementing version number - v4.0.5 (1792a62b) +* incrementing version number - v4.0.4 (b1125cce) +* incrementing version number - v4.0.3 (2b65c735) +* up harmony (ea110a0e) +* incrementing version number - v4.0.2 (73fe5fcf) +* incrementing version number - v4.0.1 (a461b758) +* bump emoji for #13077 as well (ff0de097) +* fix ap dev helper (b14494b0) +* add helper method to ease in dev (d7d64a14) +* add helper method to ease in dev (7d5482b2) +* incrementing version number - v4.0.0 (c1eaee45) +* **deps:** + * update dependency sass-embedded to v1.85.1 (#13208) (3907e6c8) + * update postgres docker tag to v17.4 (#13196) (cba2bc5e) + * update postgres docker tag to v17.3 (#13162) (47e28a0e) + * update dependency sass-embedded to v1.85.0 (#13161) (2258e145) + * update commitlint monorepo to v19.7.1 (#13123) (ca6734b3) + * update coverallsapp/github-action action to v2.3.6 (#13089) (84b28fae) + * update dependency lint-staged to v15.4.3 (#13079) (1d846134) + * update dependency mocha to v11.1.0 (#13069) (8e99c97a) + * update dependency lint-staged to v15.4.1 (#13060) (153e65bc) + * update dependency lint-staged to v15.4.1 (#13060) (37b2b83d) +* **i18n:** + * fallback strings for new resources: nodebb.category (00253821) + * fallback strings for new resources: nodebb.error (589be143) + * fallback strings for new resources: nodebb.themes-harmony (25049714) + * fallback strings for new resources: nodebb.admin-settings-advanced (ad6b6132) + * fallback strings for new resources: nodebb.themes-harmony (fc063bb0) + * fallback strings for new resources: nodebb.admin-settings-general (d41109a0) + +##### New Features + +* support remote "Video" type objects in note assertion, #13120 (95f2c4ed) +* 1b12 compatibility (7dc1e8ab) +* remove activities older than a week (d9e86c7b) +* federate out Announce of a tid's mainPid if the tid is moved out of cid -1 (b7f9983a) +* syncUserInboxes to take into account remote topic tags, closes #13074 (637addc4) +* allow search bar to load remote 7888 Conversations, aka nodebb topics (7687da00) +* introduce new 'markdown' post parsing type, closes #13077 (b386e4a6) +* #13066, report canonical URL in user agent for outgoing requests (c3e9cb68) +* changes to how a topic is presented via ActivityPub; conformance with upcoming changes to 7888 (4fd7a9dc) +* changes to how a topic is presented via ActivityPub; conformance with upcoming changes to 7888 (adeaff4b) + +##### Bug Fixes + +* scheduled topics and posts should return 404 on AP request (428300de) +* tag handling when remote objects contain tags without leading # symbol (5c3f1cfe) +* handle multiple types in remote actor payload (65895651) +* missing db (058befb3) +* remove handle on category purge (adb430f2) +* restrict feps methods to real cids (8b717d54) +* restore old behaviour of 1b12 federating both object and activity (f0ee43dc) +* send `actor` with 1b12 announce, fixes #13072 again (86b0e591) +* isArray check (5f3ed76d) +* delete from payload instead of setting null (489c5ce2) +* send `actor` with 1b12 announce, fixes #13072 (3cd87f1b) +* #13139, payload.version can be null (be1598d1) +* tidChanged (bfd4e68b) +* bad logic that invisibly broke outgoing user follows completely (334be721) +* #13076, allow pulling in of topics by their topic URL fix: reapply fixes that were part of since-reverted 4fd7a9dc59b65e8654d704c493f2254793e8d6a9 (c6e6ab43) +* call relativeToAbsolute helper when generating markdown source content in mocks.notes.public/private (02fb99eb) +* extend remoteAnchorToLocalProfile ap helper to handle markdown content (db1f8959) +* incorrect `posts` url in topic posts collection (812ec73e) +* incorrect `posts` url in topic posts collection (b2530e61) +* **deps:** + * update dependency sass to v1.85.1 (#13209) (386ab89f) + * update dependency bcryptjs to v3 (#13160) (6ea65678) + * update dependency cron to v4 (#13184) (41eec8d7) + * update dependency xregexp to v5.1.2 (#13195) (23621eca) + * update dependency ace-builds to v1.39.0 (#13197) (a3f5721a) + * update dependency chart.js to v4.4.8 (#13182) (474d267e) + * update dependency postcss to v8.5.3 (#13183) (5fc4c806) + * update dependency mongodb to v6.13.1 (#13187) (77b0160c) + * update dependency nodebb-plugin-web-push to v0.7.3 (#13178) (000ceee4) + * update dependency sass to v1.85.0 (#13163) (75a7188a) + * update dependency pg to v8.13.3 (#13157) (f3c156e9) + * update dependency pg-cursor to v2.12.3 (#13158) (6b8e4b39) + * update dependency webpack to v5.98.0 (#13159) (db74c1e8) + * update dependency nodebb-widget-essentials to v7.0.33 (#13156) (af7f4242) + * update dependency pg-cursor to v2.12.2 (#13150) (b5ce9e14) + * update dependency compression to v1.8.0 (#13152) (1e52cf34) + * update dependency ace-builds to v1.38.0 (#13151) (db0b816c) + * update dependency pg to v8.13.2 (#13149) (bea1367d) + * update dependency postcss to v8.5.2 (#13144) (3449e76d) + * update dependency benchpressjs to v2.5.3 (#13098) (6688edde) + * update dependency esbuild to v0.25.0 (#13141) (d7fdd80c) + * update dependency tough-cookie to v5.1.1 (#13140) (33ce7239) + * update dependency ioredis to v5.5.0 (#13138) (b337e999) + * update dependency sass to v1.84.0 (#13128) (f872a768) + * update dependency semver to v7.7.1 (#13122) (5f3c5a55) + * update dependency mongodb to v6.13.0 (#13106) (31ff6c2e) + * update dependency semver to v7.7.0 (#13099) (a348e808) + * update dependency nodemailer to v6.10.0 (#13073) (8ab71e4f) + * update dependency nodebb-theme-persona to v14.0.2 (#13064) (8ec3ceae) + * update dependency nodebb-theme-harmony to v2.0.3 (#13063) (b98d047a) + +##### Other Changes + +* remove unused db (06b3d9ad) +* remove tab (54bc54e1) +* fix tab (397d28e3) + +##### Performance Improvements + +* closes #13145, reduce calls in actors.prune (676acb7e) + +##### Refactors + +* remove cid:-1:tids (and variants) from intersection in /world, fixes #13125 (d0561a60) +* single remove (0784e11b) +* move 1b12 announce logic out of inbox and into separate feps module (9fd6ac6b) +* acceptable types in context.js to index.js, allow searching for remote topis by topic url (d644c0f4) +* Posts.relativeToAbsolute so that the regexes passed to it no longer need a pre-defined length, it is now calculated from the match result, added new regex for markdown image/anchors (f64e6f0f) + +##### Tests + +* moved AP actor tests to separate actors.js file, added failing test for scheduled topics (01be4d79) +* update test to assert the note assertion itself (c6ba56a5) +* update bcrypt hash for 3.x (bfffbfbe) +* update pwd test for bcrypt3.x (ca0fa1d3) +* add sourceContent to spec (d1d55461) +* adjust webfinger test for updated 404 status code (59afd193) + #### v4.0.6 (2025-02-27) ##### Chores diff --git a/install/data/defaults.json b/install/data/defaults.json index a86fa023e4..43dd55bcc6 100644 --- a/install/data/defaults.json +++ b/install/data/defaults.json @@ -147,6 +147,7 @@ "username:disableEdit": 0, "email:disableEdit": 0, "email:smtpTransport:pool": 0, + "email:smtpTransport:allow-self-signed": 0, "hideFullname": 0, "hideEmail": 0, "showFullnameAsDisplayName": 0, diff --git a/install/package.json b/install/package.json index ab1ada6165..375b88a07e 100644 --- a/install/package.json +++ b/install/package.json @@ -103,15 +103,15 @@ "nodebb-plugin-dbsearch": "6.2.13", "nodebb-plugin-emoji": "6.0.2", "nodebb-plugin-emoji-android": "4.1.1", - "nodebb-plugin-markdown": "13.1.0", - "nodebb-plugin-mentions": "4.7.0", + "nodebb-plugin-markdown": "13.1.1", + "nodebb-plugin-mentions": "4.7.1", "nodebb-plugin-spam-be-gone": "2.3.1", "nodebb-plugin-web-push": "0.7.3", "nodebb-rewards-essentials": "1.0.1", - "nodebb-theme-harmony": "2.0.37", - "nodebb-theme-lavender": "7.1.17", + "nodebb-theme-harmony": "2.0.39", + "nodebb-theme-lavender": "7.1.18", "nodebb-theme-peace": "2.2.39", - "nodebb-theme-persona": "14.0.15", + "nodebb-theme-persona": "14.0.16", "nodebb-widget-essentials": "7.0.35", "nodemailer": "6.10.0", "nprogress": "0.2.0", diff --git a/public/language/en-GB/admin/manage/categories.json b/public/language/en-GB/admin/manage/categories.json index a5c0253dae..9965edf634 100644 --- a/public/language/en-GB/admin/manage/categories.json +++ b/public/language/en-GB/admin/manage/categories.json @@ -94,6 +94,7 @@ "federation.followers-handle": "Handle", "federation.followers-id": "ID", "federation.followers-none": "No followers.", + "federation.followers-autofill": "Autofill", "alert.created": "Created", "alert.create-success": "Category successfully created!", diff --git a/public/language/en-GB/admin/settings/email.json b/public/language/en-GB/admin/settings/email.json index a3f49a0416..0310939cb3 100644 --- a/public/language/en-GB/admin/settings/email.json +++ b/public/language/en-GB/admin/settings/email.json @@ -28,6 +28,8 @@ "smtp-transport.password": "Password", "smtp-transport.pool": "Enable pooled connections", "smtp-transport.pool-help": "Pooling connections prevents NodeBB from creating a new connection for every email. This option only applies if SMTP Transport is enabled.", + "smtp-transport.allow-self-signed": "Allow self-signed certificates", + "smtp-transport.allow-self-signed-help": "Enabling this setting will allow you to use self-signed or invalid TLS certificates.", "template": "Edit Email Template", "template.select": "Select Email Template", diff --git a/src/activitypub/inbox.js b/src/activitypub/inbox.js index e9b17d92c8..4da0800670 100644 --- a/src/activitypub/inbox.js +++ b/src/activitypub/inbox.js @@ -297,7 +297,6 @@ inbox.announce = async (req) => { } ({ tid } = assertion); - await topics.updateLastPostTime(tid, timestamp); await activitypub.notes.updateLocalRecipients(pid, { to, cc }); await activitypub.notes.syncUserInboxes(tid); } diff --git a/src/controllers/admin/uploads.js b/src/controllers/admin/uploads.js index fc6ee9c1f1..56d64674cf 100644 --- a/src/controllers/admin/uploads.js +++ b/src/controllers/admin/uploads.js @@ -3,6 +3,8 @@ const path = require('path'); const nconf = require('nconf'); const fs = require('fs'); +const winston = require('winston'); +const sanitizeHtml = require('sanitize-html'); const meta = require('../../meta'); const posts = require('../../posts'); @@ -22,9 +24,15 @@ uploadsController.get = async function (req, res, next) { } const itemsPerPage = 20; const page = parseInt(req.query.page, 10) || 1; + let files = []; + try { + await checkSymLinks(req.query.dir); + files = await getFilesInFolder(currentFolder); + } catch (err) { + winston.error(err.stack); + return next(new Error('[[error:invalid-path]]')); + } try { - let files = await fs.promises.readdir(currentFolder); - files = files.filter(filename => filename !== '.gitignore'); const itemCount = files.length; const start = Math.max(0, (page - 1) * itemsPerPage); const stop = start + itemsPerPage; @@ -64,6 +72,34 @@ uploadsController.get = async function (req, res, next) { } }; +async function checkSymLinks(folder) { + let dir = path.normalize(folder || ''); + while (dir.length && dir !== '.') { + const nextPath = path.join(nconf.get('upload_path'), dir); + // eslint-disable-next-line no-await-in-loop + const stat = await fs.promises.lstat(nextPath); + if (stat.isSymbolicLink()) { + throw new Error('[[invalid-path]]'); + } + const newDir = path.dirname(dir); + if (newDir === dir) { + break; + } + dir = newDir; + } +} + +async function getFilesInFolder(folder) { + const dirents = await fs.promises.readdir(folder, { withFileTypes: true }); + const files = []; + for await (const dirent of dirents) { + if (!dirent.isSymbolicLink() && dirent.name !== '.gitignore') { + files.push(dirent.name); + } + } + return files; +} + function buildBreadcrumbs(currentFolder) { const crumbs = []; const parts = currentFolder.replace(nconf.get('upload_path'), '').split(path.sep); @@ -94,14 +130,14 @@ async function getFileData(currentDir, file) { const stat = await fs.promises.stat(pathToFile); let filesInDir = []; if (stat.isDirectory()) { - filesInDir = await fs.promises.readdir(pathToFile); + filesInDir = await getFilesInFolder(pathToFile); } const url = `${nconf.get('upload_url') + currentDir.replace(nconf.get('upload_path'), '')}/${file}`; return { name: file, path: pathToFile.replace(path.join(nconf.get('upload_path'), '/'), ''), url: url, - fileCount: Math.max(0, filesInDir.length - 1), // ignore .gitignore + fileCount: filesInDir.length, size: stat.size, sizeHumanReadable: `${(stat.size / 1024).toFixed(1)}KiB`, isDirectory: stat.isDirectory(), @@ -121,11 +157,50 @@ uploadsController.uploadCategoryPicture = async function (req, res, next) { return next(new Error('[[error:invalid-json]]')); } + if (uploadedFile.path.endsWith('.svg')) { + await sanitizeSvg(uploadedFile.path); + } + await validateUpload(uploadedFile, allowedImageTypes); const filename = `category-${params.cid}${path.extname(uploadedFile.name)}`; await uploadImage(filename, 'category', uploadedFile, req, res, next); }; +async function sanitizeSvg(filePath) { + const dirty = await fs.promises.readFile(filePath, 'utf8'); + const clean = sanitizeHtml(dirty, { + allowedTags: [ + 'svg', 'g', 'defs', 'linearGradient', 'radialGradient', 'stop', + 'circle', 'ellipse', 'polygon', 'polyline', 'path', 'rect', + 'line', 'text', 'tspan', 'use', 'symbol', 'clipPath', 'mask', 'pattern', + 'filter', 'feGaussianBlur', 'feOffset', 'feBlend', 'feColorMatrix', 'feMerge', 'feMergeNode', + ], + allowedAttributes: { + '*': [ + // Geometry + 'x', 'y', 'x1', 'x2', 'y1', 'y2', 'cx', 'cy', 'r', 'rx', 'ry', + 'width', 'height', 'd', 'points', 'viewBox', 'transform', + + // Presentation + 'fill', 'stroke', 'stroke-width', 'opacity', + 'stop-color', 'stop-opacity', 'offset', 'style', 'class', + + // Text + 'text-anchor', 'font-size', 'font-family', + + // Misc + 'id', 'clip-path', 'mask', 'filter', 'gradientUnits', 'gradientTransform', + 'xmlns', 'preserveAspectRatio', + ], + }, + parser: { + lowerCaseTags: false, + lowerCaseAttributeNames: false, + }, + }); + await fs.promises.writeFile(filePath, clean); +} + uploadsController.uploadFavicon = async function (req, res, next) { const uploadedFile = req.files.files[0]; const allowedTypes = ['image/x-icon', 'image/vnd.microsoft.icon']; @@ -197,6 +272,9 @@ uploadsController.uploadFile = async function (req, res, next) { return next(new Error('[[error:invalid-json]]')); } + if (!await file.exists(path.join(nconf.get('upload_path'), params.folder))) { + return next(new Error('[[error:invalid-path]]')); + } try { const data = await file.saveFileToLocal(uploadedFile.name, params.folder, uploadedFile.path); res.json([{ url: data.url }]); diff --git a/src/emailer.js b/src/emailer.js index 6c57d6b44a..f280c21399 100644 --- a/src/emailer.js +++ b/src/emailer.js @@ -153,7 +153,11 @@ Emailer.setupFallbackTransport = (config) => { } else { smtpOptions.service = String(config['email:smtpTransport:service']); } - + if (config['email:smtpTransport:allow-self-signed']) { + smtpOptions.tls = { + rejectUnauthorized: false, + }; + } Emailer.transports.smtp = nodemailer.createTransport(smtpOptions); Emailer.fallbackTransport = Emailer.transports.smtp; } else { diff --git a/src/messaging/unread.js b/src/messaging/unread.js index 1a63d81139..8ca893a638 100644 --- a/src/messaging/unread.js +++ b/src/messaging/unread.js @@ -18,9 +18,6 @@ module.exports = function (Messaging) { uids = [uids]; } uids = uids.filter(uid => parseInt(uid, 10) > 0); - if (!uids.length) { - return; - } uids.forEach((uid) => { io.in(`uid_${uid}`).emit('event:unread.updateChatCount', data); }); diff --git a/src/meta/configs.js b/src/meta/configs.js index 80159490c4..f5adf5b000 100644 --- a/src/meta/configs.js +++ b/src/meta/configs.js @@ -132,6 +132,7 @@ Configs.setMultiple = async function (data) { await processConfig(data); data = serialize(data); await db.setObject('config', data); + await updateNavItems(data); updateConfig(deserialize(data)); }; @@ -228,6 +229,13 @@ async function getLogoSize(data) { data['brand:emailLogo:width'] = size.width; } +async function updateNavItems(data) { + if (data.hasOwnProperty('activitypubEnabled')) { + const navAdmin = require('../navigation/admin'); + await navAdmin.update('/world', { enabled: data.activitypubEnabled ? 'on' : '' }); + } +} + function updateConfig(config) { updateLocalConfig(config); pubsub.publish('config:update', config); diff --git a/src/navigation/admin.js b/src/navigation/admin.js index df8241c8ba..f944918371 100644 --- a/src/navigation/admin.js +++ b/src/navigation/admin.js @@ -85,6 +85,19 @@ admin.get = async function () { return cache.map(item => ({ ...item })); }; +admin.update = async function (route, data) { + const ids = await db.getSortedSetRange('navigation:enabled', 0, -1); + const navItems = await db.getObjects(ids.map(id => `navigation:enabled:${id}`)); + const matchedRoutes = navItems.filter(item => item && item.route === route); + if (matchedRoutes.length) { + await db.setObjectBulk( + matchedRoutes.map(item => [`navigation:enabled:${item.order}`, data]) + ); + cache = null; + pubsub.publish('admin:navigation:save'); + } +}; + async function getAvailable() { const core = require('../../install/data/navigation.json').map((item) => { item.core = true; diff --git a/src/plugins/hooks.js b/src/plugins/hooks.js index 8a5d1a885d..553e90db45 100644 --- a/src/plugins/hooks.js +++ b/src/plugins/hooks.js @@ -275,8 +275,12 @@ async function fireActionHook(hook, hookList, params) { winston.warn(`[plugins] Expected method for hook '${hook}' in plugin '${hookObj.id}' not found, skipping.`); } } else { - // eslint-disable-next-line - await hookObj.method(params); + try { + // eslint-disable-next-line + await hookObj.method(params); + } catch (err) { + winston.error(`[plugins] Error in hook ${hookObj.id}@${hookObj.hook} \n${err.stack}`); + } } } } diff --git a/src/socket.io/admin/plugins.js b/src/socket.io/admin/plugins.js index d926dfa0cf..4489be42bd 100644 --- a/src/socket.io/admin/plugins.js +++ b/src/socket.io/admin/plugins.js @@ -23,7 +23,8 @@ Plugins.toggleActive = async function (socket, plugin_id) { Plugins.toggleInstall = async function (socket, data) { const isInstalled = await plugins.isInstalled(data.id); - if (nconf.get('acpPluginInstallDisabled') && !isInstalled) { + const isStarterPlan = nconf.get('saas_plan') === 'starter'; + if ((isStarterPlan || nconf.get('acpPluginInstallDisabled')) && !isInstalled) { throw new Error('[[error:plugin-installation-via-acp-disabled]]'); } postsCache.reset(); diff --git a/src/socket.io/meta.js b/src/socket.io/meta.js index 0e59e93849..f2181344e8 100644 --- a/src/socket.io/meta.js +++ b/src/socket.io/meta.js @@ -6,20 +6,23 @@ const user = require('../user'); const meta = require('../meta'); const topics = require('../topics'); const privileges = require('../privileges'); +const messaging = require('../messaging'); const SocketMeta = module.exports; SocketMeta.rooms = {}; -SocketMeta.reconnected = function (socket, data, callback) { - callback = callback || function () {}; - if (socket.uid) { - topics.pushUnreadCount(socket.uid); - user.notifications.pushCount(socket.uid); +SocketMeta.reconnected = async function (socket) { + if (socket.uid > 0) { + await Promise.all([ + topics.pushUnreadCount(socket.uid), + user.notifications.pushCount(socket.uid), + messaging.pushUnreadCount(socket.uid), + ]); } - callback(null, { + return { 'cache-buster': meta.config['cache-buster'], hostname: os.hostname(), - }); + }; }; /* Rooms */ diff --git a/src/topics/fork.js b/src/topics/fork.js index d86322a9df..faff873061 100644 --- a/src/topics/fork.js +++ b/src/topics/fork.js @@ -38,25 +38,29 @@ module.exports = function (Topics) { cid = await posts.getCidByPid(mainPid); } - const [postData, isAdminOrMod] = await Promise.all([ + const [mainPost, isAdminOrMod] = await Promise.all([ posts.getPostData(mainPid), privileges.categories.isAdminOrMod(cid, uid), ]); + let lastPost = mainPost; + if (pids.length > 1) { + lastPost = await posts.getPostData(pids[pids.length - 1]); + } if (!isAdminOrMod) { throw new Error('[[error:no-privileges]]'); } - - const scheduled = postData.timestamp > Date.now(); + const now = Date.now(); + const scheduled = mainPost.timestamp > now; const params = { - uid: postData.uid, + uid: mainPost.uid, title: title, cid: cid, - timestamp: scheduled && postData.timestamp, + timestamp: mainPost.timestamp, }; const result = await plugins.hooks.fire('filter:topic.fork', { params: params, - tid: postData.tid, + tid: mainPost.tid, }); const tid = await Topics.create(result.params); @@ -71,21 +75,21 @@ module.exports = function (Topics) { await Topics.movePostToTopic(uid, pid, tid, scheduled); } - await Topics.updateLastPostTime(tid, scheduled ? (postData.timestamp + 1) : Date.now()); + await Topics.updateLastPostTime(tid, scheduled ? (mainPost.timestamp + 1) : lastPost.timestamp); await Promise.all([ Topics.setTopicFields(tid, { - upvotes: postData.upvotes, - downvotes: postData.downvotes, + upvotes: mainPost.upvotes, + downvotes: mainPost.downvotes, forkedFromTid: fromTid, forkerUid: uid, - forkTimestamp: Date.now(), + forkTimestamp: now, }), - db.sortedSetsAdd(['topics:votes', `cid:${cid}:tids:votes`], postData.votes, tid), + db.sortedSetsAdd(['topics:votes', `cid:${cid}:tids:votes`], mainPost.votes, tid), Topics.events.log(fromTid, { type: 'fork', uid, href: `/topic/${tid}` }), ]); - plugins.hooks.fire('action:topic.fork', { tid: tid, fromTid: fromTid, uid: uid }); + plugins.hooks.fire('action:topic.fork', { tid, fromTid, uid }); return await Topics.getTopicData(tid); }; diff --git a/src/user/delete.js b/src/user/delete.js index 9329e5150a..a4aac56c9f 100644 --- a/src/user/delete.js +++ b/src/user/delete.js @@ -234,7 +234,9 @@ module.exports = function (User) { } async function deleteImages(uid) { - const folder = path.join(nconf.get('upload_path'), 'profile', `uid-${uid}`); - await rimraf(folder); + if (utils.isNumber(uid)) { + const folder = path.join(nconf.get('upload_path'), 'profile', `uid-${uid}`); + await rimraf(folder); + } } }; diff --git a/src/user/email.js b/src/user/email.js index c14c9c93fc..b485081713 100644 --- a/src/user/email.js +++ b/src/user/email.js @@ -124,23 +124,22 @@ UserEmail.sendValidationEmail = async function (uid, options) { }; } - const confirm_code = utils.generateUUID(); - const confirm_link = `${nconf.get('url')}/confirm/${confirm_code}`; - - const { emailConfirmInterval, emailConfirmExpiry } = meta.config; - // If no email passed in (default), retrieve email from uid if (!options.email || !options.email.length) { options.email = await user.getUserField(uid, 'email'); } if (!options.email) { + winston.warn(`[user/email] No email found for uid ${uid}`); return; } + const { emailConfirmInterval, emailConfirmExpiry } = meta.config; if (!options.force && !await UserEmail.canSendValidation(uid, options.email)) { throw new Error(`[[error:confirm-email-already-sent, ${emailConfirmInterval}]]`); } + const confirm_code = utils.generateUUID(); + const confirm_link = `${nconf.get('url')}/confirm/${confirm_code}`; const username = await user.getUserField(uid, 'username'); const data = await plugins.hooks.fire('filter:user.verify', { uid, diff --git a/src/views/admin/manage/category-federation.tpl b/src/views/admin/manage/category-federation.tpl index 9d8f1d632f..1f2652b68b 100644 --- a/src/views/admin/manage/category-federation.tpl +++ b/src/views/admin/manage/category-federation.tpl @@ -14,85 +14,85 @@ [[admin/manage/categories:federation.disabled-cta]] {{{ else }}} -