diff --git a/.codeclimate.yml b/.codeclimate.yml
index 81b8bd3c4c..b5165b6887 100644
--- a/.codeclimate.yml
+++ b/.codeclimate.yml
@@ -1,8 +1,16 @@
# Save as .codeclimate.yml (note leading .) in project root directory
+version: "2"
languages:
Ruby: true
JavaScript: true
PHP: true
+checks:
+ file-lines:
+ config:
+ threshold: 500
+ method-lines:
+ config:
+ threshold: 50
exclude_paths:
- "public/vendor/*"
- "test/*"
\ No newline at end of file
diff --git a/.eslintrc b/.eslintrc
index ef9f48dedc..9414543335 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -36,6 +36,10 @@
"no-restricted-globals": "off",
"function-paren-newline": "off",
"import/no-unresolved": "error",
+ "quotes": ["error", "single", {
+ "avoidEscape": true,
+ "allowTemplateLiterals": true
+ }],
// ES6
"prefer-rest-params": "off",
diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md
index d025ff3ff5..49f0a84909 100644
--- a/.github/ISSUE_TEMPLATE.md
+++ b/.github/ISSUE_TEMPLATE.md
@@ -10,9 +10,9 @@
- **NodeBB version:**
- **NodeBB git hash:**
-- **Database type:** mongo or redis
+- **Database type:** mongo, redis, or postgres
- **Database version:**
-
+
- **Exact steps to cause this issue:**
', nested[i].html()));
- }
+ nested.forEach(function (nestedEl, i) {
+ result.html(result.html().replace('', function () {
+ return nestedEl.html();
+ }));
+ });
});
$('.search-result-text').find('img:not(.not-responsive)').addClass('img-responsive');
diff --git a/public/src/client/topic.js b/public/src/client/topic.js
index 63c63e3051..a73f884482 100644
--- a/public/src/client/topic.js
+++ b/public/src/client/topic.js
@@ -59,8 +59,8 @@ define('forum/topic', [
}
addBlockQuoteHandler();
-
addParentHandler();
+ addDropupHandler();
navigator.init('[component="post"]', ajaxify.data.postcount, Topic.toTop, Topic.toBottom, Topic.navigatorCallback, Topic.calculateIndex);
@@ -166,6 +166,17 @@ define('forum/topic', [
});
}
+ function addDropupHandler() {
+ // Locate all dropdowns
+ var target = $('#content .dropdown-menu').parent();
+
+ // Toggle dropup if past 50% of screen
+ $(target).on('show.bs.dropdown', function () {
+ var dropUp = this.getBoundingClientRect().top > ($(window).height() / 2);
+ $(this).toggleClass('dropup', dropUp);
+ });
+ }
+
function updateTopicTitle() {
var span = components.get('navbar/title').find('span');
if ($(window).scrollTop() > 50 && span.hasClass('hidden')) {
diff --git a/public/src/client/topic/delete-posts.js b/public/src/client/topic/delete-posts.js
index fb2c9f7f43..803d957d18 100644
--- a/public/src/client/topic/delete-posts.js
+++ b/public/src/client/topic/delete-posts.js
@@ -18,6 +18,10 @@ define('forum/topic/delete-posts', ['components', 'postSelect'], function (compo
}
function onDeletePostsClicked() {
+ if (modal) {
+ return;
+ }
+
app.parseAndTranslate('partials/delete_posts_modal', {}, function (html) {
modal = html;
diff --git a/public/src/client/topic/events.js b/public/src/client/topic/events.js
index ac4c41e96b..544d8039a6 100644
--- a/public/src/client/topic/events.js
+++ b/public/src/client/topic/events.js
@@ -159,6 +159,8 @@ define('forum/topic/events', [
});
});
}
+
+ postTools.removeMenu(components.get('post', 'pid', data.post.pid));
}
function tagsUpdated(tags) {
diff --git a/public/src/client/topic/fork.js b/public/src/client/topic/fork.js
index f4190b61ac..134a41a1ac 100644
--- a/public/src/client/topic/fork.js
+++ b/public/src/client/topic/fork.js
@@ -17,6 +17,10 @@ define('forum/topic/fork', ['components', 'postSelect'], function (components, p
}
function onForkThreadClicked() {
+ if (forkModal) {
+ return;
+ }
+
app.parseAndTranslate('partials/fork_thread_modal', {}, function (html) {
forkModal = html;
diff --git a/public/src/client/topic/merge.js b/public/src/client/topic/merge.js
index f250e19f50..fd4eb592ed 100644
--- a/public/src/client/topic/merge.js
+++ b/public/src/client/topic/merge.js
@@ -25,7 +25,7 @@ define('forum/topic/merge', function () {
modal.find('.close,#merge_topics_cancel').on('click', closeModal);
- $('[component="category"]').on('click', '[component="category/topic"] a', onTopicClicked);
+ $('#content').on('click', '[component="category"] [component="category/topic"] a', onTopicClicked);
showTopicsSelected();
@@ -101,7 +101,7 @@ define('forum/topic/merge', function () {
modal = null;
}
selectedTids = {};
- $('[component="category"]').off('click', '[component="category/topic"] a', onTopicClicked);
+ $('#content').off('click', '[component="category"] [component="category/topic"] a', onTopicClicked);
}
return Merge;
diff --git a/public/src/client/topic/postTools.js b/public/src/client/topic/postTools.js
index 8d460d7ae3..1608811166 100644
--- a/public/src/client/topic/postTools.js
+++ b/public/src/client/topic/postTools.js
@@ -66,6 +66,10 @@ define('forum/topic/postTools', [
postEl.find('[component="post/restore"]').toggleClass('hidden', !isDeleted);
postEl.find('[component="post/purge"]').toggleClass('hidden', !isDeleted);
+ PostTools.removeMenu(postEl);
+ };
+
+ PostTools.removeMenu = function (postEl) {
postEl.find('[component="post/tools"] .dropdown-menu').html('');
};
@@ -338,7 +342,7 @@ define('forum/topic/postTools', [
}
if (post.length) {
- slug = post.attr('data-userslug');
+ slug = utils.slugify(post.attr('data-username'), true);
}
if (post.length && post.attr('data-uid') !== '0') {
slug = '@' + slug;
diff --git a/public/src/client/topic/votes.js b/public/src/client/topic/votes.js
index ee4813d173..1d17e03d46 100644
--- a/public/src/client/topic/votes.js
+++ b/public/src/client/topic/votes.js
@@ -72,6 +72,10 @@ define('forum/topic/votes', ['components', 'translator', 'benchpress'], function
if (err) {
app.alertError(err.message);
}
+
+ if (err && err.message === '[[error:not-logged-in]]') {
+ ajaxify.go('login');
+ }
});
return false;
diff --git a/public/src/client/users.js b/public/src/client/users.js
index 3d541e0863..bf6873e693 100644
--- a/public/src/client/users.js
+++ b/public/src/client/users.js
@@ -130,7 +130,7 @@ define('forum/users', ['translator', 'benchpress'], function (translator, Benchp
function handleInvite() {
$('[component="user/invite"]').on('click', function () {
- bootbox.prompt('Email: ', function (email) {
+ bootbox.prompt('[[users:prompt-email]]', function (email) {
if (!email) {
return;
}
diff --git a/public/src/modules/chat.js b/public/src/modules/chat.js
index 558229c4a6..8ec6cd0300 100644
--- a/public/src/modules/chat.js
+++ b/public/src/modules/chat.js
@@ -75,7 +75,7 @@ define('chat', [
sounds.play('chat-incoming', 'chat.incoming:' + data.message.mid);
taskbar.push('chat', modal.attr('data-uuid'), {
- title: data.roomName || username,
+ title: '[[modules:chat.chatting_with]] ' + (data.roomName || username),
touid: data.message.fromUser.uid,
roomId: data.roomId,
});
@@ -136,7 +136,8 @@ define('chat', [
rooms: rooms,
}, function (html) {
translator.translate(html, function (translated) {
- chatsListEl.empty().html(translated);
+ chatsListEl.find('*').not('.navigation-link').remove();
+ chatsListEl.prepend(translated);
app.createUserTooltips(chatsListEl, 'right');
});
});
@@ -211,12 +212,12 @@ define('chat', [
chatModal.find('.modal-header').on('dblclick', gotoChats);
chatModal.find('button[data-action="maximize"]').on('click', gotoChats);
chatModal.find('button[data-action="minimize"]').on('click', function () {
- var uuid = chatModal.attr('uuid');
+ var uuid = chatModal.attr('data-uuid');
module.minimize(uuid);
});
- chatModal.on('click', function () {
- taskbar.updateActive(this.getAttribute('data-uuid'));
+ chatModal.on('click', ':not(.close)', function () {
+ taskbar.updateActive(chatModal.attr('data-uuid'));
if (dragged) {
dragged = false;
@@ -250,7 +251,7 @@ define('chat', [
Chats.addIPHandler(chatModal);
taskbar.push('chat', chatModal.attr('data-uuid'), {
- title: data.roomName || (data.users.length ? data.users[0].username : ''),
+ title: '[[modules:chat.chatting_with]] ' + (data.roomName || (data.users.length ? data.users[0].username : '')),
roomId: data.roomId,
icon: 'fa-comment',
state: '',
diff --git a/public/src/modules/handleBack.js b/public/src/modules/handleBack.js
index 6a11ebc45e..94e7125f52 100644
--- a/public/src/modules/handleBack.js
+++ b/public/src/modules/handleBack.js
@@ -35,10 +35,14 @@ define('handleBack', [
storage.removeItem('category:bookmark');
storage.removeItem('category:bookmark:clicked');
+ if (!utils.isNumber(bookmarkIndex)) {
+ return;
+ }
bookmarkIndex = Math.max(0, parseInt(bookmarkIndex, 10) || 0);
clickedIndex = Math.max(0, parseInt(clickedIndex, 10) || 0);
- if (!utils.isNumber(bookmarkIndex)) {
+
+ if (!bookmarkIndex && !clickedIndex) {
return;
}
diff --git a/public/src/modules/helpers.js b/public/src/modules/helpers.js
index 2171eedfba..9e0eb2088c 100644
--- a/public/src/modules/helpers.js
+++ b/public/src/modules/helpers.js
@@ -185,7 +185,10 @@
}
}
return states.map(function (priv) {
- return '
';
+ var guestDisabled = ['groups:moderate', 'groups:posts:upvote', 'groups:posts:downvote'];
+ var disabled = member === 'guests' && guestDisabled.includes(priv.name);
+
+ return ' ';
}).join('');
}
diff --git a/public/src/modules/pictureCropper.js b/public/src/modules/pictureCropper.js
index 60e7f2708c..0d8fe3a3ca 100644
--- a/public/src/modules/pictureCropper.js
+++ b/public/src/modules/pictureCropper.js
@@ -50,6 +50,7 @@ define('pictureCropper', ['translator', 'cropper', 'benchpress'], function (tran
aspectRatio: data.aspectRatio,
autoCropArea: 1,
viewMode: 1,
+ checkCrossOrigin: false,
cropmove: function () {
if (data.restrictImageDimension) {
if (cropperTool.cropBoxData.width > data.imageDimension) {
@@ -96,7 +97,17 @@ define('pictureCropper', ['translator', 'cropper', 'benchpress'], function (tran
cropperModal.find('.crop-btn').on('click', function () {
$(this).addClass('disabled');
- var imageData = data.imageType ? cropperTool.getCroppedCanvas().toDataURL(data.imageType) : cropperTool.getCroppedCanvas().toDataURL();
+ var imageData;
+ try {
+ imageData = data.imageType ? cropperTool.getCroppedCanvas().toDataURL(data.imageType) : cropperTool.getCroppedCanvas().toDataURL();
+ } catch (err) {
+ if (err.message === 'Failed to execute \'toDataURL\' on \'HTMLCanvasElement\': Tainted canvases may not be exported.') {
+ app.alertError('[[error:cors-error]]');
+ } else {
+ app.alertError(err.message);
+ }
+ return;
+ }
cropperModal.find('#upload-progress-bar').css('width', '100%');
cropperModal.find('#upload-progress-box').show().removeClass('hide');
@@ -138,13 +149,11 @@ define('pictureCropper', ['translator', 'cropper', 'benchpress'], function (tran
function onSubmit(data, callback) {
function showAlert(type, message) {
- module.hideAlerts(data.uploadModal);
if (type === 'error') {
data.uploadModal.find('#fileUploadSubmitBtn').removeClass('disabled');
}
data.uploadModal.find('#alert-' + type).translateText(message).removeClass('hide');
}
-
var fileInput = data.uploadModal.find('#fileInput');
if (!fileInput.val()) {
return showAlert('error', '[[uploads:select-file-to-upload]]');
@@ -154,7 +163,10 @@ define('pictureCropper', ['translator', 'cropper', 'benchpress'], function (tran
var reader = new FileReader();
var imageUrl;
var imageType = file.type;
-
+ var fileSize = data.hasOwnProperty('fileSize') && data.fileSize !== undefined ? parseInt(data.fileSize, 10) : false;
+ if (fileSize && file.size > fileSize * 1024) {
+ return app.alertError('[[error:file-too-big, ' + fileSize + ']]');
+ }
reader.addEventListener('load', function () {
imageUrl = reader.result;
diff --git a/public/src/modules/search.js b/public/src/modules/search.js
index f135271d50..ea0f775fd1 100644
--- a/public/src/modules/search.js
+++ b/public/src/modules/search.js
@@ -7,24 +7,14 @@ define('search', ['navigator', 'translator', 'storage'], function (nav, translat
};
Search.query = function (data, callback) {
- var term = data.term;
-
// Detect if a tid was specified
- var topicSearch = term.match(/^in:topic-([\d]+) /);
+ var topicSearch = data.term.match(/^in:topic-([\d]+) /);
if (!topicSearch) {
- term = term.replace(/^[ ?#]*/, '');
-
- try {
- term = encodeURIComponent(term);
- } catch (e) {
- return app.alertError('[[error:invalid-search-term]]');
- }
-
ajaxify.go('search?' + createQueryString(data));
callback();
} else {
- var cleanedTerm = term.replace(topicSearch[0], '');
+ var cleanedTerm = data.term.replace(topicSearch[0], '');
var tid = topicSearch[1];
if (cleanedTerm.length > 0) {
@@ -36,8 +26,15 @@ define('search', ['navigator', 'translator', 'storage'], function (nav, translat
function createQueryString(data) {
var searchIn = data.in || 'titlesposts';
var postedBy = data.by || '';
+ var term = data.term.replace(/^[ ?#]*/, '');
+ try {
+ term = encodeURIComponent(term);
+ } catch (e) {
+ return app.alertError('[[error:invalid-search-term]]');
+ }
+
var query = {
- term: data.term,
+ term: term,
in: searchIn,
};
diff --git a/public/src/modules/taskbar.js b/public/src/modules/taskbar.js
index 1e3ea17e10..5a1c0d886f 100644
--- a/public/src/modules/taskbar.js
+++ b/public/src/modules/taskbar.js
@@ -1,7 +1,7 @@
'use strict';
-define('taskbar', ['benchpress'], function (Benchpress) {
+define('taskbar', ['benchpress', 'translator'], function (Benchpress, translator) {
var taskbar = {};
taskbar.init = function () {
@@ -111,32 +111,35 @@ define('taskbar', ['benchpress'], function (Benchpress) {
}
function createTaskbar(data) {
- var title = $('
').text(data.options.title || 'NodeBB Task').html();
+ translator.translate(data.options.title, function (taskTitle) {
+ var title = $('
').text(taskTitle || 'NodeBB Task').html();
- var taskbarEl = $(' ')
- .addClass(data.options.className)
- .html('' +
- (data.options.icon ? ' ' : '') +
- (data.options.image ? ' ' : '') +
- '' + title + ' ' +
- ' ')
- .attr({
- 'data-module': data.module,
- 'data-uuid': data.uuid,
- })
- .addClass(data.options.state !== undefined ? data.options.state : 'active');
+ var taskbarEl = $(' ')
+ .addClass(data.options.className)
+ .html('' +
+ (data.options.icon ? ' ' : '') +
+ (data.options.image ? ' ' : '') +
+ '' + title + ' ' +
+ ' ')
+ .attr({
+ title: title,
+ 'data-module': data.module,
+ 'data-uuid': data.uuid,
+ })
+ .addClass(data.options.state !== undefined ? data.options.state : 'active');
- if (!data.options.state || data.options.state === 'active') {
- minimizeAll();
- }
+ if (!data.options.state || data.options.state === 'active') {
+ minimizeAll();
+ }
- taskbar.tasklist.append(taskbarEl);
- update();
+ taskbar.tasklist.append(taskbarEl);
+ update();
- data.element = taskbarEl;
+ data.element = taskbarEl;
- taskbarEl.data(data);
- $(window).trigger('action:taskbar.pushed', data);
+ taskbarEl.data(data);
+ $(window).trigger('action:taskbar.pushed', data);
+ });
}
taskbar.updateTitle = function (module, uuid, newTitle) {
diff --git a/public/src/require-config.js b/public/src/require-config.js
index a7c70ac70e..2657d4aa33 100644
--- a/public/src/require-config.js
+++ b/public/src/require-config.js
@@ -2,7 +2,7 @@
require.config({
baseUrl: config.relative_path + '/assets/src/modules',
- waitSeconds: 7,
+ waitSeconds: 0,
urlArgs: config['cache-buster'],
paths: {
forum: '../client',
diff --git a/src/categories.js b/src/categories.js
index 1270924f0e..93d2c87e54 100644
--- a/src/categories.js
+++ b/src/categories.js
@@ -95,7 +95,7 @@ Categories.getAllCategories = function (uid, callback) {
], callback);
};
-Categories.getCategoriesByPrivilege = function (set, uid, privilege, callback) {
+Categories.getCidsByPrivilege = function (set, uid, privilege, callback) {
async.waterfall([
function (next) {
db.getSortedSetRange(set, 0, -1, next);
@@ -103,6 +103,14 @@ Categories.getCategoriesByPrivilege = function (set, uid, privilege, callback) {
function (cids, next) {
privileges.categories.filterCids(privilege, cids, uid, next);
},
+ ], callback);
+};
+
+Categories.getCategoriesByPrivilege = function (set, uid, privilege, callback) {
+ async.waterfall([
+ function (next) {
+ Categories.getCidsByPrivilege(set, uid, privilege, next);
+ },
function (cids, next) {
Categories.getCategories(cids, uid, next);
},
@@ -378,3 +386,5 @@ Categories.filterIgnoringUids = function (cid, uids, callback) {
},
], callback);
};
+
+Categories.async = require('./promisify')(Categories);
diff --git a/src/categories/create.js b/src/categories/create.js
index 1d6fc6c09f..835515c2b4 100644
--- a/src/categories/create.js
+++ b/src/categories/create.js
@@ -35,7 +35,7 @@ module.exports = function (Categories) {
parentCid: parentCid,
topic_count: 0,
post_count: 0,
- disabled: 0,
+ disabled: data.disabled ? 1 : 0,
order: order,
link: data.link || '',
numRecentReplies: 1,
@@ -84,10 +84,24 @@ module.exports = function (Categories) {
], next);
},
function (results, next) {
- if (data.cloneFromCid && parseInt(data.cloneFromCid, 10)) {
- return Categories.copySettingsFrom(data.cloneFromCid, category.cid, !data.parentCid, next);
- }
- next(null, category);
+ async.series([
+ function (next) {
+ if (data.cloneFromCid && parseInt(data.cloneFromCid, 10)) {
+ return Categories.copySettingsFrom(data.cloneFromCid, category.cid, !data.parentCid, next);
+ }
+
+ next();
+ },
+ function (next) {
+ if (data.cloneChildren) {
+ return duplicateCategoriesChildren(category.cid, data.cloneFromCid, data.uid, next);
+ }
+
+ next();
+ },
+ ], function (err) {
+ next(err, category);
+ });
},
function (category, next) {
plugins.fireHook('action:category.create', { category: category });
@@ -96,6 +110,27 @@ module.exports = function (Categories) {
], callback);
};
+ function duplicateCategoriesChildren(parentCid, cid, uid, callback) {
+ Categories.getChildren([cid], uid, function (err, children) {
+ if (err || !children.length) {
+ return callback(err);
+ }
+
+ children = children[0];
+
+ children.forEach(function (child) {
+ child.parentCid = parentCid;
+ child.cloneFromCid = child.cid;
+ child.cloneChildren = true;
+ child.name = utils.decodeHTMLEntities(child.name);
+ child.description = utils.decodeHTMLEntities(child.description);
+ child.uid = uid;
+ });
+
+ async.each(children, Categories.create, callback);
+ });
+ }
+
Categories.assignColours = function () {
var backgrounds = ['#AB4642', '#DC9656', '#F7CA88', '#A1B56C', '#86C1B9', '#7CAFC2', '#BA8BAF', '#A16946'];
var text = ['#fff', '#fff', '#333', '#fff', '#333', '#fff', '#fff', '#fff'];
diff --git a/src/categories/data.js b/src/categories/data.js
index a1a9d5c587..8eb24b2179 100644
--- a/src/categories/data.js
+++ b/src/categories/data.js
@@ -28,9 +28,15 @@ module.exports = function (Categories) {
return;
}
- category.name = validator.escape(String(category.name || ''));
- category.disabled = category.hasOwnProperty('disabled') ? parseInt(category.disabled, 10) === 1 : undefined;
- category.isSection = category.hasOwnProperty('isSection') ? parseInt(category.isSection, 10) === 1 : undefined;
+ if (category.hasOwnProperty('name')) {
+ category.name = validator.escape(String(category.name || ''));
+ }
+ if (category.hasOwnProperty('disabled')) {
+ category.disabled = parseInt(category.disabled, 10) === 1;
+ }
+ if (category.hasOwnProperty('isSection')) {
+ category.isSection = parseInt(category.isSection, 10) === 1;
+ }
if (category.hasOwnProperty('icon')) {
category.icon = category.icon || 'hidden';
diff --git a/src/cli/index.js b/src/cli/index.js
index cece70d554..20f7c85271 100644
--- a/src/cli/index.js
+++ b/src/cli/index.js
@@ -186,8 +186,9 @@ program
program
.command('build [targets...]')
.description('Compile static assets ' + '(JS, CSS, templates, languages, sounds)'.red)
- .action(function (targets) {
- require('./manage').build(targets.length ? targets : true);
+ .option('-s, --series', 'Run builds in series without extra processes')
+ .action(function (targets, options) {
+ require('./manage').build(targets.length ? targets : true, options);
})
.on('--help', function () {
require('./manage').buildTargets();
diff --git a/src/cli/reset.js b/src/cli/reset.js
index 44d78df961..d3675d2ee4 100644
--- a/src/cli/reset.js
+++ b/src/cli/reset.js
@@ -11,6 +11,7 @@ var events = require('../events');
var meta = require('../meta');
var plugins = require('../plugins');
var widgets = require('../widgets');
+var privileges = require('../privileges');
var dirname = require('./paths').baseDir;
@@ -86,9 +87,13 @@ exports.reset = function (options, callback) {
};
function resetSettings(callback) {
- meta.configs.set('allowLocalLogin', 1, function (err) {
+ privileges.global.give(['local:login'], 'registered-users', function (err) {
+ if (err) {
+ return callback(err);
+ }
+ winston.info('[reset] registered-users given login privilege');
winston.info('[reset] Settings reset to default');
- callback(err);
+ callback();
});
}
diff --git a/src/cli/upgrade.js b/src/cli/upgrade.js
index 38df2594cb..618183111e 100644
--- a/src/cli/upgrade.js
+++ b/src/cli/upgrade.js
@@ -41,6 +41,7 @@ var steps = {
handler: function (next) {
async.series([
db.init,
+ require('../meta').configs.init,
upgrade.run,
], next);
},
diff --git a/src/controllers/accounts.js b/src/controllers/accounts.js
index 0f31595ef4..8a7f1f1db6 100644
--- a/src/controllers/accounts.js
+++ b/src/controllers/accounts.js
@@ -4,13 +4,14 @@ var accountsController = {
profile: require('./accounts/profile'),
edit: require('./accounts/edit'),
info: require('./accounts/info'),
+ categories: require('./accounts/categories'),
settings: require('./accounts/settings'),
groups: require('./accounts/groups'),
follow: require('./accounts/follow'),
posts: require('./accounts/posts'),
notifications: require('./accounts/notifications'),
chats: require('./accounts/chats'),
- session: require('./accounts/session'),
+ sessions: require('./accounts/sessions'),
blocks: require('./accounts/blocks'),
uploads: require('./accounts/uploads'),
consent: require('./accounts/consent'),
diff --git a/src/controllers/accounts/categories.js b/src/controllers/accounts/categories.js
new file mode 100644
index 0000000000..43eff07889
--- /dev/null
+++ b/src/controllers/accounts/categories.js
@@ -0,0 +1,43 @@
+'use strict';
+
+var async = require('async');
+
+var user = require('../../user');
+var categories = require('../../categories');
+var accountHelpers = require('./helpers');
+
+var categoriesController = module.exports;
+
+categoriesController.get = function (req, res, callback) {
+ var userData;
+ async.waterfall([
+ function (next) {
+ accountHelpers.getUserDataByUserSlug(req.params.userslug, req.uid, next);
+ },
+ function (_userData, next) {
+ userData = _userData;
+ if (!userData) {
+ return callback();
+ }
+
+ async.parallel({
+ ignored: function (next) {
+ user.getIgnoredCategories(userData.uid, next);
+ },
+ categories: function (next) {
+ categories.buildForSelect(userData.uid, 'find', next);
+ },
+ }, next);
+ },
+ function (results) {
+ results.categories.forEach(function (category) {
+ if (category) {
+ category.isIgnored = results.ignored.includes(String(category.cid));
+ }
+ });
+ userData.categories = results.categories;
+ userData.title = '[[pages:account/watched_categories, ' + userData.username + ']]';
+ res.render('account/categories', userData);
+ },
+ ], callback);
+};
diff --git a/src/controllers/accounts/helpers.js b/src/controllers/accounts/helpers.js
index 1d2a30272e..12f7674563 100644
--- a/src/controllers/accounts/helpers.js
+++ b/src/controllers/accounts/helpers.js
@@ -65,6 +65,17 @@ helpers.getUserDataByUserSlug = function (userslug, callerUID, callback) {
globalMod: true,
admin: true,
},
+ }, {
+ id: 'sessions',
+ route: 'sessions',
+ name: '[[pages:account/sessions]]',
+ visibility: {
+ self: true,
+ other: false,
+ moderator: false,
+ globalMod: false,
+ admin: false,
+ },
}, {
id: 'consent',
route: 'consent',
diff --git a/src/controllers/accounts/session.js b/src/controllers/accounts/sessions.js
similarity index 57%
rename from src/controllers/accounts/session.js
rename to src/controllers/accounts/sessions.js
index 809cdb6dad..1609fa2383 100644
--- a/src/controllers/accounts/session.js
+++ b/src/controllers/accounts/sessions.js
@@ -4,8 +4,36 @@ var async = require('async');
var db = require('../../database');
var user = require('../../user');
+var helpers = require('../helpers');
+var accountHelpers = require('./helpers');
-var sessionController = {};
+var sessionController = module.exports;
+
+sessionController.get = function (req, res, callback) {
+ var userData;
+
+ async.waterfall([
+ function (next) {
+ accountHelpers.getUserDataByUserSlug(req.params.userslug, req.uid, next);
+ },
+ function (_userData, next) {
+ userData = _userData;
+ if (!userData) {
+ return callback();
+ }
+
+ user.auth.getSessions(userData.uid, req.sessionID, next);
+ },
+ function (sessions) {
+ userData.sessions = sessions;
+
+ userData.title = '[[pages:account/sessions]]';
+ userData.breadcrumbs = helpers.buildBreadcrumbs([{ text: userData.username, url: '/user/' + userData.userslug }, { text: '[[pages:account/sessions]]' }]);
+
+ res.render('account/sessions', userData);
+ },
+ ], callback);
+};
sessionController.revoke = function (req, res, next) {
if (!req.params.hasOwnProperty('uuid')) {
@@ -50,5 +78,3 @@ sessionController.revoke = function (req, res, next) {
return res.sendStatus(200);
});
};
-
-module.exports = sessionController;
diff --git a/src/controllers/accounts/settings.js b/src/controllers/accounts/settings.js
index 5f5bb57aa7..0beab3f77a 100644
--- a/src/controllers/accounts/settings.js
+++ b/src/controllers/accounts/settings.js
@@ -148,7 +148,9 @@ settingsController.get = function (req, res, callback) {
var notifFreqOptions = [
'all',
+ 'first',
'everyTen',
+ 'threshold',
'logarithmic',
'disabled',
];
@@ -156,7 +158,7 @@ settingsController.get = function (req, res, callback) {
userData.upvoteNotifFreq = notifFreqOptions.map(function (name) {
return {
name: name,
- selected: name === userData.notifFreqOptions,
+ selected: name === userData.settings.upvoteNotifFreq,
};
});
diff --git a/src/controllers/admin/database.js b/src/controllers/admin/database.js
index efec771ee6..147747822c 100644
--- a/src/controllers/admin/database.js
+++ b/src/controllers/admin/database.js
@@ -25,6 +25,14 @@ databaseController.get = function (req, res, next) {
next();
}
},
+ postgres: function (next) {
+ if (nconf.get('postgres')) {
+ var pdb = require('../../database/postgres');
+ pdb.info(pdb.pool, next);
+ } else {
+ next();
+ }
+ },
}, next);
},
function (results) {
diff --git a/src/controllers/admin/events.js b/src/controllers/admin/events.js
index f6988ef72e..88a2848f37 100644
--- a/src/controllers/admin/events.js
+++ b/src/controllers/admin/events.js
@@ -14,24 +14,35 @@ eventsController.get = function (req, res, next) {
var start = (page - 1) * itemsPerPage;
var stop = start + itemsPerPage - 1;
+ var currentFilter = req.query.filter || '';
+
async.waterfall([
function (next) {
async.parallel({
eventCount: function (next) {
- db.sortedSetCard('events:time', next);
+ db.sortedSetCard('events:time' + (currentFilter ? ':' + currentFilter : ''), next);
},
events: function (next) {
- events.getEvents(start, stop, next);
+ events.getEvents(currentFilter, start, stop, next);
},
}, next);
},
function (results) {
+ var types = [''].concat(events.types);
+ var filters = types.map(function (type) {
+ return {
+ value: type,
+ name: type || 'all',
+ selected: type === currentFilter,
+ };
+ });
+
var pageCount = Math.max(1, Math.ceil(results.eventCount / itemsPerPage));
res.render('admin/advanced/events', {
events: results.events,
- pagination: pagination.create(page, pageCount),
- next: 20,
+ pagination: pagination.create(page, pageCount, req.query),
+ filters: filters,
});
},
], next);
diff --git a/src/controllers/admin/postqueue.js b/src/controllers/admin/postqueue.js
index 66ce1e237f..1812d70e95 100644
--- a/src/controllers/admin/postqueue.js
+++ b/src/controllers/admin/postqueue.js
@@ -1,6 +1,7 @@
'use strict';
var async = require('async');
+var validator = require('validator');
var db = require('../../database');
var user = require('../../user');
@@ -80,7 +81,8 @@ function getQueuedPosts(ids, callback) {
});
async.map(postData, function (postData, next) {
- postData.data.rawContent = postData.data.content;
+ postData.data.rawContent = validator.escape(String(postData.data.content));
+ postData.data.title = validator.escape(String(postData.data.title));
async.waterfall([
function (next) {
if (postData.data.cid) {
diff --git a/src/controllers/admin/privileges.js b/src/controllers/admin/privileges.js
index 92dbe27ef9..08bfe3183a 100644
--- a/src/controllers/admin/privileges.js
+++ b/src/controllers/admin/privileges.js
@@ -2,6 +2,7 @@
var async = require('async');
+var db = require('../../database');
var categories = require('../../categories');
var privileges = require('../../privileges');
@@ -19,7 +20,19 @@ privilegesController.get = function (req, res, callback) {
privileges.categories.list(cid, next);
}
},
- allCategories: async.apply(categories.buildForSelect, req.uid, 'read'),
+ allCategories: function (next) {
+ async.waterfall([
+ function (next) {
+ db.getSortedSetRange('cid:0:children', 0, -1, next);
+ },
+ function (cids, next) {
+ categories.getCategories(cids, req.uid, next);
+ },
+ function (categoriesData, next) {
+ categories.buildForSelectCategories(categoriesData, next);
+ },
+ ], next);
+ },
}, next);
},
function (data) {
diff --git a/src/controllers/admin/uploads.js b/src/controllers/admin/uploads.js
index 21dbbbca99..c7952ee98c 100644
--- a/src/controllers/admin/uploads.js
+++ b/src/controllers/admin/uploads.js
@@ -5,7 +5,6 @@ var async = require('async');
var nconf = require('nconf');
var mime = require('mime');
var fs = require('fs');
-var jimp = require('jimp');
var meta = require('../../meta');
var posts = require('../../posts');
@@ -177,16 +176,13 @@ uploadsController.uploadTouchIcon = function (req, res, next) {
}
// Resize the image into squares for use as touch icons at various DPIs
- async.each(sizes, function (size, next) {
- async.series([
- async.apply(file.saveFileToLocal, 'touchicon-' + size + '.png', 'system', uploadedFile.path),
- async.apply(image.resizeImage, {
- path: path.join(nconf.get('upload_path'), 'system', 'touchicon-' + size + '.png'),
- extension: 'png',
- width: size,
- height: size,
- }),
- ], next);
+ async.eachSeries(sizes, function (size, next) {
+ image.resizeImage({
+ path: uploadedFile.path,
+ target: path.join(nconf.get('upload_path'), 'system', 'touchicon-' + size + '.png'),
+ width: size,
+ height: size,
+ }, next);
}, function (err) {
file.delete(uploadedFile.path);
@@ -291,7 +287,6 @@ function uploadImage(filename, folder, uploadedFile, req, res, next) {
async.apply(image.resizeImage, {
path: uploadedFile.path,
target: uploadPath,
- extension: 'png',
height: 50,
}),
async.apply(meta.configs.set, 'brand:emailLogo', path.join(nconf.get('upload_url'), 'system/site-logo-x50.png')),
@@ -299,15 +294,16 @@ function uploadImage(filename, folder, uploadedFile, req, res, next) {
next(err, imageData);
});
} else if (path.basename(filename, path.extname(filename)) === 'og:image' && folder === 'system') {
- jimp.read(imageData.path).then(function (image) {
+ image.size(imageData.path, function (err, size) {
+ if (err) {
+ next(err);
+ }
meta.configs.setMultiple({
- 'og:image:height': image.bitmap.height,
- 'og:image:width': image.bitmap.width,
+ 'og:image:width': size.width,
+ 'og:image:height': size.height,
}, function (err) {
next(err, imageData);
});
- }).catch(function (err) {
- next(err);
});
} else {
setImmediate(next, null, imageData);
diff --git a/src/controllers/authentication.js b/src/controllers/authentication.js
index 643b898843..941ec5bcc9 100644
--- a/src/controllers/authentication.js
+++ b/src/controllers/authentication.js
@@ -12,10 +12,9 @@ var meta = require('../meta');
var user = require('../user');
var plugins = require('../plugins');
var utils = require('../utils');
-var Password = require('../password');
var translator = require('../translator');
var helpers = require('./helpers');
-
+var privileges = require('../privileges');
var sockets = require('../socket.io');
var authenticationController = module.exports;
@@ -398,23 +397,25 @@ authenticationController.localLogin = function (req, username, password, next) {
uid = _uid;
async.parallel({
- userData: function (next) {
- db.getObjectFields('user:' + uid, ['password', 'passwordExpiry'], next);
- },
+ userData: async.apply(db.getObjectFields, 'user:' + uid, ['passwordExpiry']),
isAdminOrGlobalMod: function (next) {
user.isAdminOrGlobalMod(uid, next);
},
banned: function (next) {
user.isBanned(uid, next);
},
+ hasLoginPrivilege: function (next) {
+ privileges.global.can('local:login', uid, next);
+ },
}, next);
},
function (result, next) {
- userData = result.userData;
- userData.uid = uid;
- userData.isAdminOrGlobalMod = result.isAdminOrGlobalMod;
+ userData = Object.assign(result.userData, {
+ uid: uid,
+ isAdminOrGlobalMod: result.isAdminOrGlobalMod,
+ });
- if (!result.isAdminOrGlobalMod && parseInt(meta.config.allowLocalLogin, 10) === 0) {
+ if (parseInt(uid, 10) && !result.hasLoginPrivilege) {
return next(new Error('[[error:local-login-disabled]]'));
}
@@ -422,16 +423,13 @@ authenticationController.localLogin = function (req, username, password, next) {
return getBanInfo(uid, next);
}
- user.auth.logAttempt(uid, req.ip, next);
- },
- function (next) {
- Password.compare(password, userData.password, next);
+ user.isPasswordCorrect(uid, password, req.ip, next);
},
function (passwordMatch, next) {
if (!passwordMatch) {
return next(new Error('[[error:invalid-login-credentials]]'));
}
- user.auth.clearLoginAttempts(uid);
+
next(null, userData, '[[success:authentication-successful]]');
},
], next);
diff --git a/src/controllers/categories.js b/src/controllers/categories.js
index 1eb46a0041..b15db0bbff 100644
--- a/src/controllers/categories.js
+++ b/src/controllers/categories.js
@@ -47,11 +47,12 @@ categoriesController.list = function (req, res, next) {
}
data.categories.forEach(function (category) {
- if (category && Array.isArray(category.posts) && category.posts.length) {
+ if (category && Array.isArray(category.posts) && category.posts.length && category.posts[0]) {
category.teaser = {
url: nconf.get('relative_path') + '/post/' + category.posts[0].pid,
timestampISO: category.posts[0].timestampISO,
pid: category.posts[0].pid,
+ topic: category.posts[0].topic,
};
}
});
diff --git a/src/controllers/groups.js b/src/controllers/groups.js
index 0703915e6f..6063653fc0 100644
--- a/src/controllers/groups.js
+++ b/src/controllers/groups.js
@@ -7,8 +7,9 @@ var meta = require('../meta');
var groups = require('../groups');
var user = require('../user');
var helpers = require('./helpers');
+var pagination = require('../pagination');
-var groupsController = {};
+var groupsController = module.exports;
groupsController.list = function (req, res, next) {
var sort = req.query.sort || 'alpha';
@@ -113,7 +114,12 @@ groupsController.details = function (req, res, callback) {
};
groupsController.members = function (req, res, callback) {
+ var page = parseInt(req.query.page, 10) || 1;
+ var usersPerPage = 50;
+ var start = Math.max(0, (page - 1) * usersPerPage);
+ var stop = start + usersPerPage - 1;
var groupName;
+ var groupData;
async.waterfall([
function (next) {
groups.getGroupNameByGroupSlug(req.params.slug, next);
@@ -127,14 +133,16 @@ groupsController.members = function (req, res, callback) {
isAdminOrGlobalMod: async.apply(user.isAdminOrGlobalMod, req.uid),
isMember: async.apply(groups.isMember, req.uid, groupName),
isHidden: async.apply(groups.isHidden, groupName),
+ groupData: async.apply(groups.getGroupData, groupName),
}, next);
},
function (results, next) {
if (results.isHidden && !results.isMember && !results.isAdminOrGlobalMod) {
return callback();
}
+ groupData = results.groupData;
- user.getUsersFromSet('group:' + groupName + ':members', req.uid, 0, 49, next);
+ user.getUsersFromSet('group:' + groupName + ':members', req.uid, start, stop, next);
},
function (users) {
var breadcrumbs = helpers.buildBreadcrumbs([
@@ -143,10 +151,10 @@ groupsController.members = function (req, res, callback) {
{ text: '[[groups:details.members]]' },
]);
+ var pageCount = Math.max(1, Math.ceil(groupData.memberCount / usersPerPage));
res.render('groups/members', {
users: users,
- nextStart: 50,
- loadmore_display: users.length > 50 ? 'block' : 'hide',
+ pagination: pagination.create(page, pageCount, req.query),
breadcrumbs: breadcrumbs,
});
},
@@ -177,5 +185,3 @@ groupsController.uploadCover = function (req, res, next) {
res.json([{ url: image.url }]);
});
};
-
-module.exports = groupsController;
diff --git a/src/controllers/helpers.js b/src/controllers/helpers.js
index 4b41c62dfb..636abd77bb 100644
--- a/src/controllers/helpers.js
+++ b/src/controllers/helpers.js
@@ -12,6 +12,7 @@ var categories = require('../categories');
var plugins = require('../plugins');
var meta = require('../meta');
var middleware = require('../middleware');
+var utils = require('../utils');
var helpers = module.exports;
@@ -227,19 +228,38 @@ helpers.buildTitle = function (pageTitle) {
return title;
};
+helpers.getCategories = function (set, uid, privilege, selectedCid, callback) {
+ async.waterfall([
+ function (next) {
+ categories.getCidsByPrivilege(set, uid, privilege, next);
+ },
+ function (cids, next) {
+ getCategoryData(cids, uid, selectedCid, next);
+ },
+ ], callback);
+};
+
helpers.getWatchedCategories = function (uid, selectedCid, callback) {
- if (selectedCid && !Array.isArray(selectedCid)) {
- selectedCid = [selectedCid];
- }
async.waterfall([
function (next) {
user.getWatchedCategories(uid, next);
},
function (cids, next) {
+ getCategoryData(cids, uid, selectedCid, next);
+ },
+ ], callback);
+};
+
+function getCategoryData(cids, uid, selectedCid, callback) {
+ if (selectedCid && !Array.isArray(selectedCid)) {
+ selectedCid = [selectedCid];
+ }
+ async.waterfall([
+ function (next) {
privileges.categories.filterCids('read', cids, uid, next);
},
function (cids, next) {
- categories.getCategoriesFields(cids, ['cid', 'name', 'slug', 'icon', 'link', 'color', 'bgColor', 'parentCid'], next);
+ categories.getCategoriesFields(cids, ['cid', 'name', 'slug', 'icon', 'link', 'color', 'bgColor', 'parentCid', 'image', 'imageClass'], next);
},
function (categoryData, next) {
categoryData = categoryData.filter(function (category) {
@@ -249,6 +269,7 @@ helpers.getWatchedCategories = function (uid, selectedCid, callback) {
var selectedCids = [];
categoryData.forEach(function (category) {
category.selected = selectedCid ? selectedCid.indexOf(String(category.cid)) !== -1 : false;
+ category.parentCid = category.hasOwnProperty('parentCid') && utils.isNumber(category.parentCid) ? category.parentCid : 0;
if (category.selected) {
selectedCategory.push(category);
selectedCids.push(parseInt(category.cid, 10));
@@ -280,7 +301,7 @@ helpers.getWatchedCategories = function (uid, selectedCid, callback) {
next(null, { categories: categoriesData, selectedCategory: selectedCategory, selectedCids: selectedCids });
},
], callback);
-};
+}
function recursive(category, categoriesData, level) {
category.level = level;
diff --git a/src/controllers/home.js b/src/controllers/home.js
index 35a6cfe6a0..49cee7e192 100644
--- a/src/controllers/home.js
+++ b/src/controllers/home.js
@@ -1,6 +1,8 @@
'use strict';
var async = require('async');
+var url = require('url');
+
var plugins = require('../plugins');
var meta = require('../meta');
var user = require('../user');
@@ -18,7 +20,7 @@ function getUserHomeRoute(uid, callback) {
var route = adminHomePageRoute();
if (settings.homePageRoute !== 'undefined' && settings.homePageRoute !== 'none') {
- route = settings.homePageRoute || route;
+ route = (settings.homePageRoute || route).replace(/^\/+/, '');
}
next(null, route);
@@ -40,14 +42,22 @@ function rewrite(req, res, next) {
}
},
function (route, next) {
- var hook = 'action:homepage.get:' + route;
-
- if (!plugins.hasListeners(hook)) {
- req.url = req.path + (!req.path.endsWith('/') ? '/' : '') + route;
- } else {
- res.locals.homePageRoute = route;
+ var parsedUrl;
+ try {
+ parsedUrl = url.parse(route, true);
+ } catch (err) {
+ return next(err);
}
+ var pathname = parsedUrl.pathname;
+ var hook = 'action:homepage.get:' + pathname;
+ if (!plugins.hasListeners(hook)) {
+ req.url = req.path + (!req.path.endsWith('/') ? '/' : '') + pathname;
+ } else {
+ res.locals.homePageRoute = pathname;
+ }
+ req.query = Object.assign(parsedUrl.query, req.query);
+
next();
},
], next);
diff --git a/src/controllers/index.js b/src/controllers/index.js
index a91636dfc7..a183692d4e 100644
--- a/src/controllers/index.js
+++ b/src/controllers/index.js
@@ -7,6 +7,7 @@ var validator = require('validator');
var meta = require('../meta');
var user = require('../user');
var plugins = require('../plugins');
+var privileges = require('../privileges');
var helpers = require('./helpers');
var Controllers = module.exports;
@@ -106,7 +107,6 @@ Controllers.login = function (req, res, next) {
data.alternate_logins = loginStrategies.length > 0;
data.authentication = loginStrategies;
- data.allowLocalLogin = parseInt(meta.config.allowLocalLogin, 10) === 1 || parseInt(req.query.local, 10) === 1;
data.allowRegistration = registrationType === 'normal' || registrationType === 'admin-approval' || registrationType === 'admin-approval-ip';
data.allowLoginWith = '[[login:' + allowLoginWith + ']]';
data.breadcrumbs = helpers.buildBreadcrumbs([{
@@ -115,26 +115,33 @@ Controllers.login = function (req, res, next) {
data.error = req.flash('error')[0] || errorText;
data.title = '[[pages:login]]';
- if (!data.allowLocalLogin && !data.allowRegistration && data.alternate_logins && data.authentication.length === 1) {
- if (res.locals.isAPI) {
- return helpers.redirect(res, {
- external: nconf.get('relative_path') + data.authentication[0].url,
- });
+ privileges.global.canGroup('local:login', 'registered-users', function (err, hasLoginPrivilege) {
+ if (err) {
+ return next(err);
}
- return res.redirect(nconf.get('relative_path') + data.authentication[0].url);
- }
- if (req.loggedIn) {
- user.getUserFields(req.uid, ['username', 'email'], function (err, user) {
- if (err) {
- return next(err);
+
+ data.allowLocalLogin = hasLoginPrivilege || parseInt(req.query.local, 10) === 1;
+ if (!data.allowLocalLogin && !data.allowRegistration && data.alternate_logins && data.authentication.length === 1) {
+ if (res.locals.isAPI) {
+ return helpers.redirect(res, {
+ external: nconf.get('relative_path') + data.authentication[0].url,
+ });
}
- data.username = allowLoginWith === 'email' ? user.email : user.username;
- data.alternate_logins = false;
+ return res.redirect(nconf.get('relative_path') + data.authentication[0].url);
+ }
+ if (req.loggedIn) {
+ user.getUserFields(req.uid, ['username', 'email'], function (err, user) {
+ if (err) {
+ return next(err);
+ }
+ data.username = allowLoginWith === 'email' ? user.email : user.username;
+ data.alternate_logins = false;
+ res.render('login', data);
+ });
+ } else {
res.render('login', data);
- });
- } else {
- res.render('login', data);
- }
+ }
+ });
};
Controllers.register = function (req, res, next) {
diff --git a/src/controllers/unread.js b/src/controllers/unread.js
index 500cfe11a6..96dc4f66ce 100644
--- a/src/controllers/unread.js
+++ b/src/controllers/unread.js
@@ -54,12 +54,6 @@ unreadController.get = function (req, res, next) {
cutoff: cutoff,
}, next);
},
- function (data, next) {
- user.blocks.filter(req.uid, data.topics, function (err, filtered) {
- data.topics = filtered;
- next(err, data);
- });
- },
function (data) {
data.title = meta.config.homePageTitle || '[[pages:home]]';
data.pageCount = Math.max(1, Math.ceil(data.topicCount / settings.topicsPerPage));
diff --git a/src/controllers/uploads.js b/src/controllers/uploads.js
index f837d58529..4421438cda 100644
--- a/src/controllers/uploads.js
+++ b/src/controllers/uploads.js
@@ -25,7 +25,7 @@ uploadsController.upload = function (req, res, filesIterator) {
files = files[0];
}
- async.map(files, filesIterator, function (err, images) {
+ async.mapSeries(files, filesIterator, function (err, images) {
deleteTempFiles(files);
if (err) {
@@ -56,6 +56,9 @@ function uploadAsImage(req, uploadedFile, callback) {
if (!canUpload) {
return next(new Error('[[error:no-privileges]]'));
}
+ image.checkDimensions(uploadedFile.path, next);
+ },
+ function (next) {
if (plugins.hasListeners('filter:uploadImage')) {
return plugins.fireHook('filter:uploadImage', {
image: uploadedFile,
@@ -113,25 +116,16 @@ function resizeImage(fileObj, callback) {
return callback(null, fileObj);
}
- var dirname = path.dirname(fileObj.path);
- var extname = path.extname(fileObj.path);
- var basename = path.basename(fileObj.path, extname);
-
image.resizeImage({
path: fileObj.path,
- target: path.join(dirname, basename + '-resized' + extname),
- extension: extname,
+ target: file.appendToFileName(fileObj.path, '-resized'),
width: parseInt(meta.config.maximumImageWidth, 10) || 760,
quality: parseInt(meta.config.resizeImageQuality, 10) || 60,
}, next);
},
function (next) {
// Return the resized version to the composer/postData
- var dirname = path.dirname(fileObj.url);
- var extname = path.extname(fileObj.url);
- var basename = path.basename(fileObj.url, extname);
-
- fileObj.url = dirname + '/' + basename + '-resized' + extname;
+ fileObj.url = file.appendToFileName(fileObj.url, '-resized');
next(null, fileObj);
},
@@ -157,7 +151,6 @@ uploadsController.uploadThumb = function (req, res, next) {
var size = parseInt(meta.config.topicThumbSize, 10) || 120;
image.resizeImage({
path: uploadedFile.path,
- extension: path.extname(uploadedFile.name),
width: size,
height: size,
}, next);
diff --git a/src/database/mongo.js b/src/database/mongo.js
index 5b264253f5..9ab35feba2 100644
--- a/src/database/mongo.js
+++ b/src/database/mongo.js
@@ -98,6 +98,7 @@ mongoModule.getConnectionOptions = function () {
reconnectTries: 3600,
reconnectInterval: 1000,
autoReconnect: true,
+ useNewUrlParser: true,
};
return _.merge(connOptions, nconf.get('mongo:options') || {});
@@ -118,7 +119,6 @@ mongoModule.init = function (callback) {
}
client = _client;
db = client.db();
-
mongoModule.client = db;
require('./mongo/main')(db, mongoModule);
@@ -126,6 +126,10 @@ mongoModule.init = function (callback) {
require('./mongo/sets')(db, mongoModule);
require('./mongo/sorted')(db, mongoModule);
require('./mongo/list')(db, mongoModule);
+ require('./mongo/transaction')(db, mongoModule);
+
+ mongoModule.async = require('../promisify')(mongoModule, ['client', 'sessionStore']);
+
callback();
});
};
diff --git a/src/database/mongo/hash.js b/src/database/mongo/hash.js
index bfbeb5cb6c..1298e6e292 100644
--- a/src/database/mongo/hash.js
+++ b/src/database/mongo/hash.js
@@ -46,7 +46,7 @@ module.exports = function (db, module) {
if (data.hasOwnProperty('')) {
delete data[''];
}
- db.collection('objects').update({ _key: key }, { $set: data }, { upsert: true, w: 1 }, function (err) {
+ db.collection('objects').updateOne({ _key: key }, { $set: data }, { upsert: true, w: 1 }, function (err) {
if (err) {
return callback(err);
}
@@ -208,8 +208,8 @@ module.exports = function (db, module) {
}
var data = {};
field = helpers.fieldToString(field);
- data[field] = '';
- db.collection('objects').findOne({ _key: key }, { fields: data }, function (err, item) {
+ data[field] = 1;
+ db.collection('objects').findOne({ _key: key }, { projection: data }, function (err, item) {
callback(err, !!item && item[field] !== undefined && item[field] !== null);
});
};
@@ -222,10 +222,10 @@ module.exports = function (db, module) {
var data = {};
fields.forEach(function (field) {
field = helpers.fieldToString(field);
- data[field] = '';
+ data[field] = 1;
});
- db.collection('objects').findOne({ _key: key }, { fields: data }, function (err, item) {
+ db.collection('objects').findOne({ _key: key }, { projection: data }, function (err, item) {
if (err) {
return callback(err);
}
@@ -259,7 +259,7 @@ module.exports = function (db, module) {
data[field] = '';
});
- db.collection('objects').update({ _key: key }, { $unset: data }, function (err) {
+ db.collection('objects').updateOne({ _key: key }, { $unset: data }, function (err) {
if (err) {
return callback(err);
}
@@ -317,7 +317,7 @@ module.exports = function (db, module) {
}
- db.collection('objects').findAndModify({ _key: key }, {}, { $inc: data }, { new: true, upsert: true }, function (err, result) {
+ db.collection('objects').findOneAndUpdate({ _key: key }, { $inc: data }, { returnOriginal: false, upsert: true }, function (err, result) {
if (err) {
return callback(err);
}
diff --git a/src/database/mongo/list.js b/src/database/mongo/list.js
index b0b87ae922..219cd53be5 100644
--- a/src/database/mongo/list.js
+++ b/src/database/mongo/list.js
@@ -18,7 +18,7 @@ module.exports = function (db, module) {
}
if (exists) {
- db.collection('objects').update({ _key: key }, { $push: { array: { $each: [value], $position: 0 } } }, { upsert: true, w: 1 }, function (err) {
+ db.collection('objects').updateOne({ _key: key }, { $push: { array: { $each: [value], $position: 0 } } }, { upsert: true, w: 1 }, function (err) {
callback(err);
});
} else {
@@ -33,7 +33,7 @@ module.exports = function (db, module) {
return callback();
}
value = helpers.valueToString(value);
- db.collection('objects').update({ _key: key }, { $push: { array: value } }, { upsert: true, w: 1 }, function (err) {
+ db.collection('objects').updateOne({ _key: key }, { $push: { array: value } }, { upsert: true, w: 1 }, function (err) {
callback(err);
});
};
@@ -48,7 +48,7 @@ module.exports = function (db, module) {
return callback(err);
}
- db.collection('objects').update({ _key: key }, { $pop: { array: 1 } }, function (err) {
+ db.collection('objects').updateOne({ _key: key }, { $pop: { array: 1 } }, function (err) {
callback(err, (value && value.length) ? value[0] : null);
});
});
@@ -61,7 +61,7 @@ module.exports = function (db, module) {
}
value = helpers.valueToString(value);
- db.collection('objects').update({ _key: key }, { $pull: { array: value } }, function (err) {
+ db.collection('objects').updateOne({ _key: key }, { $pull: { array: value } }, function (err) {
callback(err);
});
};
@@ -76,7 +76,7 @@ module.exports = function (db, module) {
return callback(err);
}
- db.collection('objects').update({ _key: key }, { $set: { array: value } }, function (err) {
+ db.collection('objects').updateOne({ _key: key }, { $set: { array: value } }, function (err) {
callback(err);
});
});
diff --git a/src/database/mongo/main.js b/src/database/mongo/main.js
index 55a29e6ff9..1b23bc3409 100644
--- a/src/database/mongo/main.js
+++ b/src/database/mongo/main.js
@@ -12,7 +12,7 @@ module.exports = function (db, module) {
module.emptydb = function (callback) {
callback = callback || helpers.noop;
- db.collection('objects').remove({}, function (err) {
+ db.collection('objects').deleteMany({}, function (err) {
if (err) {
return callback(err);
}
@@ -35,7 +35,7 @@ module.exports = function (db, module) {
if (!key) {
return callback();
}
- db.collection('objects').remove({ _key: key }, function (err) {
+ db.collection('objects').deleteMany({ _key: key }, function (err) {
if (err) {
return callback(err);
}
@@ -49,7 +49,7 @@ module.exports = function (db, module) {
if (!Array.isArray(keys) || !keys.length) {
return callback();
}
- db.collection('objects').remove({ _key: { $in: keys } }, function (err) {
+ db.collection('objects').deleteMany({ _key: { $in: keys } }, function (err) {
if (err) {
return callback(err);
}
@@ -97,14 +97,14 @@ module.exports = function (db, module) {
if (!key) {
return callback();
}
- db.collection('objects').findAndModify({ _key: key }, {}, { $inc: { data: 1 } }, { new: true, upsert: true }, function (err, result) {
+ db.collection('objects').findOneAndUpdate({ _key: key }, { $inc: { data: 1 } }, { returnOriginal: false, upsert: true }, function (err, result) {
callback(err, result && result.value ? result.value.data : null);
});
};
module.rename = function (oldKey, newKey, callback) {
callback = callback || helpers.noop;
- db.collection('objects').update({ _key: oldKey }, { $set: { _key: newKey } }, { multi: true }, function (err) {
+ db.collection('objects').updateMany({ _key: oldKey }, { $set: { _key: newKey } }, function (err) {
if (err) {
return callback(err);
}
diff --git a/src/database/mongo/sets.js b/src/database/mongo/sets.js
index 4f807b3922..b66772639c 100644
--- a/src/database/mongo/sets.js
+++ b/src/database/mongo/sets.js
@@ -13,7 +13,7 @@ module.exports = function (db, module) {
array[index] = helpers.valueToString(element);
});
- db.collection('objects').update({
+ db.collection('objects').updateOne({
_key: key,
}, {
$addToSet: {
@@ -74,7 +74,7 @@ module.exports = function (db, module) {
callback(err);
});
} else {
- db.collection('objects').update({ _key: key }, { $pullAll: { members: value } }, function (err) {
+ db.collection('objects').updateOne({ _key: key }, { $pullAll: { members: value } }, function (err) {
callback(err);
});
}
@@ -87,7 +87,7 @@ module.exports = function (db, module) {
}
value = helpers.valueToString(value);
- db.collection('objects').update({ _key: { $in: keys } }, { $pull: { members: value } }, { multi: true }, function (err) {
+ db.collection('objects').updateMany({ _key: { $in: keys } }, { $pull: { members: value } }, function (err) {
callback(err);
});
};
@@ -98,7 +98,7 @@ module.exports = function (db, module) {
}
value = helpers.valueToString(value);
- db.collection('objects').findOne({ _key: key, members: value }, { _id: 0, members: 0 }, function (err, item) {
+ db.collection('objects').findOne({ _key: key, members: value }, { projection: { _id: 0, members: 0 } }, function (err, item) {
callback(err, item !== null && item !== undefined);
});
};
@@ -112,7 +112,7 @@ module.exports = function (db, module) {
values[i] = helpers.valueToString(values[i]);
}
- db.collection('objects').findOne({ _key: key }, { _id: 0, _key: 0 }, function (err, items) {
+ db.collection('objects').findOne({ _key: key }, { projection: { _id: 0, _key: 0 } }, function (err, items) {
if (err) {
return callback(err);
}
@@ -131,7 +131,7 @@ module.exports = function (db, module) {
}
value = helpers.valueToString(value);
- db.collection('objects').find({ _key: { $in: sets }, members: value }, { _id: 0, members: 0 }).toArray(function (err, result) {
+ db.collection('objects').find({ _key: { $in: sets }, members: value }, { projection: { _id: 0, members: 0 } }).toArray(function (err, result) {
if (err) {
return callback(err);
}
@@ -184,7 +184,7 @@ module.exports = function (db, module) {
if (!key) {
return callback(null, 0);
}
- db.collection('objects').findOne({ _key: key }, { _id: 0 }, function (err, data) {
+ db.collection('objects').findOne({ _key: key }, { projection: { _id: 0 } }, function (err, data) {
callback(err, data ? data.members.length : 0);
});
};
diff --git a/src/database/mongo/sorted.js b/src/database/mongo/sorted.js
index d4eb7a8522..9ec82f5a68 100644
--- a/src/database/mongo/sorted.js
+++ b/src/database/mongo/sorted.js
@@ -32,9 +32,9 @@ module.exports = function (db, module) {
return callback();
}
- var fields = { _id: 0, value: 1 };
- if (withScores) {
- fields.score = 1;
+ var fields = { _id: 0, _key: 0 };
+ if (!withScores) {
+ fields.score = 0;
}
if (Array.isArray(key)) {
@@ -62,7 +62,7 @@ module.exports = function (db, module) {
limit = 0;
}
- db.collection('objects').find({ _key: key }, { fields: fields })
+ db.collection('objects').find({ _key: key }, { projection: fields })
.limit(limit)
.skip(start)
.sort({ score: sort })
@@ -70,6 +70,7 @@ module.exports = function (db, module) {
if (err || !data) {
return callback(err);
}
+
if (reverse) {
data.reverse();
}
@@ -117,12 +118,12 @@ module.exports = function (db, module) {
query.score.$lte = max;
}
- var fields = { _id: 0, value: 1 };
- if (withScores) {
- fields.score = 1;
+ var fields = { _id: 0, _key: 0 };
+ if (!withScores) {
+ fields.score = 0;
}
- db.collection('objects').find(query, { fields: fields })
+ db.collection('objects').find(query, { projection: fields })
.limit(count)
.skip(start)
.sort({ score: sort })
@@ -155,7 +156,7 @@ module.exports = function (db, module) {
query.score.$lte = max;
}
- db.collection('objects').count(query, function (err, count) {
+ db.collection('objects').countDocuments(query, function (err, count) {
callback(err, count || 0);
});
};
@@ -164,7 +165,7 @@ module.exports = function (db, module) {
if (!key) {
return callback(null, 0);
}
- db.collection('objects').count({ _key: key }, function (err, count) {
+ db.collection('objects').countDocuments({ _key: key }, function (err, count) {
count = parseInt(count, 10);
callback(err, count || 0);
});
@@ -220,7 +221,7 @@ module.exports = function (db, module) {
return callback(err, null);
}
- db.collection('objects').count({
+ db.collection('objects').countDocuments({
$or: [
{
_key: key,
@@ -273,7 +274,7 @@ module.exports = function (db, module) {
return callback(null, null);
}
value = helpers.valueToString(value);
- db.collection('objects').findOne({ _key: key, value: value }, { fields: { _id: 0, score: 1 } }, function (err, result) {
+ db.collection('objects').findOne({ _key: key, value: value }, { projection: { _id: 0, _key: 0, value: 0 } }, function (err, result) {
callback(err, result ? result.score : null);
});
};
@@ -283,7 +284,7 @@ module.exports = function (db, module) {
return callback();
}
value = helpers.valueToString(value);
- db.collection('objects').find({ _key: { $in: keys }, value: value }, { _id: 0, _key: 1, score: 1 }).toArray(function (err, result) {
+ db.collection('objects').find({ _key: { $in: keys }, value: value }, { projection: { _id: 0, value: 0 } }).toArray(function (err, result) {
if (err) {
return callback(err);
}
@@ -306,7 +307,7 @@ module.exports = function (db, module) {
return callback(null, null);
}
values = values.map(helpers.valueToString);
- db.collection('objects').find({ _key: key, value: { $in: values } }, { _id: 0, value: 1, score: 1 }).toArray(function (err, result) {
+ db.collection('objects').find({ _key: key, value: { $in: values } }, { projection: { _id: 0, _key: 0 } }).toArray(function (err, result) {
if (err) {
return callback(err);
}
@@ -333,7 +334,7 @@ module.exports = function (db, module) {
return callback();
}
value = helpers.valueToString(value);
- db.collection('objects').findOne({ _key: key, value: value }, { _id: 0, value: 1 }, function (err, result) {
+ db.collection('objects').findOne({ _key: key, value: value }, { projection: { _id: 0, _key: 0, score: 0 } }, function (err, result) {
callback(err, !!result);
});
};
@@ -343,17 +344,19 @@ module.exports = function (db, module) {
return callback();
}
values = values.map(helpers.valueToString);
- db.collection('objects').find({ _key: key, value: { $in: values } }, { fields: { _id: 0, value: 1 } }).toArray(function (err, results) {
+ db.collection('objects').find({ _key: key, value: { $in: values } }, { projection: { _id: 0, _key: 0, score: 0 } }).toArray(function (err, results) {
if (err) {
return callback(err);
}
-
- results = results.map(function (item) {
- return item.value;
+ var isMember = {};
+ results.forEach(function (item) {
+ if (item) {
+ isMember[item.value] = true;
+ }
});
values = values.map(function (value) {
- return results.indexOf(value) !== -1;
+ return !!isMember[value];
});
callback(null, values);
});
@@ -364,17 +367,19 @@ module.exports = function (db, module) {
return callback();
}
value = helpers.valueToString(value);
- db.collection('objects').find({ _key: { $in: keys }, value: value }, { fields: { _id: 0, _key: 1, value: 1 } }).toArray(function (err, results) {
+ db.collection('objects').find({ _key: { $in: keys }, value: value }, { projection: { _id: 0, score: 0 } }).toArray(function (err, results) {
if (err) {
return callback(err);
}
-
- results = results.map(function (item) {
- return item._key;
+ var isMember = {};
+ results.forEach(function (item) {
+ if (item) {
+ isMember[item._key] = true;
+ }
});
results = keys.map(function (key) {
- return results.indexOf(key) !== -1;
+ return !!isMember[key];
});
callback(null, results);
});
@@ -384,7 +389,7 @@ module.exports = function (db, module) {
if (!Array.isArray(keys) || !keys.length) {
return callback(null, []);
}
- db.collection('objects').find({ _key: { $in: keys } }, { _id: 0, _key: 1, value: 1 }).sort({ score: 1 }).toArray(function (err, data) {
+ db.collection('objects').find({ _key: { $in: keys } }, { projection: { _id: 0, score: 0 } }).sort({ score: 1 }).toArray(function (err, data) {
if (err) {
return callback(err);
}
@@ -412,7 +417,7 @@ module.exports = function (db, module) {
value = helpers.valueToString(value);
data.score = parseFloat(increment);
- db.collection('objects').findAndModify({ _key: key, value: value }, {}, { $inc: data }, { new: true, upsert: true }, function (err, result) {
+ db.collection('objects').findOneAndUpdate({ _key: key, value: value }, { $inc: data }, { returnOriginal: false, upsert: true }, function (err, result) {
// if there is duplicate key error retry the upsert
// https://github.com/NodeBB/NodeBB/issues/4467
// https://jira.mongodb.org/browse/SERVER-14322
@@ -448,7 +453,7 @@ module.exports = function (db, module) {
var query = { _key: key };
buildLexQuery(query, min, max);
- db.collection('objects').find(query, { _id: 0, value: 1 })
+ db.collection('objects').find(query, { projection: { _id: 0, _key: 0, score: 0 } })
.sort({ value: sort })
.skip(start)
.limit(count === -1 ? 0 : count)
@@ -469,7 +474,7 @@ module.exports = function (db, module) {
var query = { _key: key };
buildLexQuery(query, min, max);
- db.collection('objects').remove(query, function (err) {
+ db.collection('objects').deleteMany(query, function (err) {
callback(err);
});
};
@@ -499,13 +504,12 @@ module.exports = function (db, module) {
module.processSortedSet = function (setKey, processFn, options, callback) {
var done = false;
var ids = [];
- var project = { _id: 0, value: 1 };
- if (options.withScores) {
- project.score = 1;
+ var project = { _id: 0, _key: 0 };
+ if (!options.withScores) {
+ project.score = 0;
}
- var cursor = db.collection('objects').find({ _key: setKey })
+ var cursor = db.collection('objects').find({ _key: setKey }, { projection: project })
.sort({ score: 1 })
- .project(project)
.batchSize(options.batch);
async.whilst(
diff --git a/src/database/mongo/sorted/add.js b/src/database/mongo/sorted/add.js
index b90501feee..e71bb0568a 100644
--- a/src/database/mongo/sorted/add.js
+++ b/src/database/mongo/sorted/add.js
@@ -14,7 +14,7 @@ module.exports = function (db, module) {
value = helpers.valueToString(value);
- db.collection('objects').update({ _key: key, value: value }, { $set: { score: parseFloat(score) } }, { upsert: true, w: 1 }, function (err) {
+ db.collection('objects').updateOne({ _key: key, value: value }, { $set: { score: parseFloat(score) } }, { upsert: true, w: 1 }, function (err) {
if (err && err.message.startsWith('E11000 duplicate key error')) {
return process.nextTick(module.sortedSetAdd, key, score, value, callback);
}
diff --git a/src/database/mongo/sorted/remove.js b/src/database/mongo/sorted/remove.js
index c9bf121e10..ab4fd90b33 100644
--- a/src/database/mongo/sorted/remove.js
+++ b/src/database/mongo/sorted/remove.js
@@ -11,17 +11,21 @@ module.exports = function (db, module) {
if (!key) {
return callback();
}
- if (Array.isArray(key) && Array.isArray(value)) {
- db.collection('objects').remove({ _key: { $in: key }, value: { $in: value } }, done);
- } else if (Array.isArray(value)) {
+
+ if (Array.isArray(value)) {
value = value.map(helpers.valueToString);
- db.collection('objects').remove({ _key: key, value: { $in: value } }, done);
- } else if (Array.isArray(key)) {
- value = helpers.valueToString(value);
- db.collection('objects').remove({ _key: { $in: key }, value: value }, done);
} else {
value = helpers.valueToString(value);
- db.collection('objects').remove({ _key: key, value: value }, done);
+ }
+
+ if (Array.isArray(key) && Array.isArray(value)) {
+ db.collection('objects').deleteMany({ _key: { $in: key }, value: { $in: value } }, done);
+ } else if (Array.isArray(value)) {
+ db.collection('objects').deleteMany({ _key: key, value: { $in: value } }, done);
+ } else if (Array.isArray(key)) {
+ db.collection('objects').deleteMany({ _key: { $in: key }, value: value }, done);
+ } else {
+ db.collection('objects').deleteOne({ _key: key, value: value }, done);
}
};
@@ -32,7 +36,7 @@ module.exports = function (db, module) {
}
value = helpers.valueToString(value);
- db.collection('objects').remove({ _key: { $in: keys }, value: value }, function (err) {
+ db.collection('objects').deleteMany({ _key: { $in: keys }, value: value }, function (err) {
callback(err);
});
};
@@ -52,7 +56,7 @@ module.exports = function (db, module) {
query.score.$lte = parseFloat(max);
}
- db.collection('objects').remove(query, function (err) {
+ db.collection('objects').deleteMany(query, function (err) {
callback(err);
});
};
diff --git a/src/database/mongo/transaction.js b/src/database/mongo/transaction.js
new file mode 100644
index 0000000000..75ea5fbaa2
--- /dev/null
+++ b/src/database/mongo/transaction.js
@@ -0,0 +1,8 @@
+'use strict';
+
+module.exports = function (db, module) {
+ // TODO
+ module.transaction = function (perform, callback) {
+ perform(db, callback);
+ };
+};
diff --git a/src/database/postgres.js b/src/database/postgres.js
new file mode 100644
index 0000000000..f06c962c22
--- /dev/null
+++ b/src/database/postgres.js
@@ -0,0 +1,465 @@
+'use strict';
+
+var winston = require('winston');
+var async = require('async');
+var nconf = require('nconf');
+var session = require('express-session');
+var _ = require('lodash');
+var semver = require('semver');
+var dbNamespace = require('continuation-local-storage').createNamespace('postgres');
+var db;
+
+var postgresModule = module.exports;
+
+postgresModule.questions = [
+ {
+ name: 'postgres:host',
+ description: 'Host IP or address of your PostgreSQL instance',
+ default: nconf.get('postgres:host') || '127.0.0.1',
+ },
+ {
+ name: 'postgres:port',
+ description: 'Host port of your PostgreSQL instance',
+ default: nconf.get('postgres:port') || 5432,
+ },
+ {
+ name: 'postgres:username',
+ description: 'PostgreSQL username',
+ default: nconf.get('postgres:username') || '',
+ },
+ {
+ name: 'postgres:password',
+ description: 'Password of your PostgreSQL database',
+ hidden: true,
+ default: nconf.get('postgres:password') || '',
+ before: function (value) { value = value || nconf.get('postgres:password') || ''; return value; },
+ },
+ {
+ name: 'postgres:database',
+ description: 'PostgreSQL database name',
+ default: nconf.get('postgres:database') || 'nodebb',
+ },
+];
+
+postgresModule.helpers = postgresModule.helpers || {};
+postgresModule.helpers.postgres = require('./postgres/helpers');
+
+postgresModule.getConnectionOptions = function () {
+ // Sensible defaults for PostgreSQL, if not set
+ if (!nconf.get('postgres:host')) {
+ nconf.set('postgres:host', '127.0.0.1');
+ }
+ if (!nconf.get('postgres:port')) {
+ nconf.set('postgres:port', 5432);
+ }
+ if (!nconf.get('postgres:database')) {
+ nconf.set('postgres:database', 'nodebb');
+ }
+
+ var connOptions = {
+ host: nconf.get('postgres:host'),
+ port: nconf.get('postgres:port'),
+ user: nconf.get('postgres:username'),
+ password: nconf.get('postgres:password'),
+ database: nconf.get('postgres:database'),
+ };
+
+ return _.merge(connOptions, nconf.get('postgres:options') || {});
+};
+
+postgresModule.init = function (callback) {
+ callback = callback || function () { };
+
+ var Pool = require('pg').Pool;
+
+ var connOptions = postgresModule.getConnectionOptions();
+
+ db = new Pool(connOptions);
+
+ db.on('connect', function (client) {
+ var realQuery = client.query;
+ client.query = function () {
+ var args = Array.prototype.slice.call(arguments, 0);
+ if (dbNamespace.active && typeof args[args.length - 1] === 'function') {
+ args[args.length - 1] = dbNamespace.bind(args[args.length - 1]);
+ }
+ return realQuery.apply(client, args);
+ };
+ });
+
+ db.connect(function (err, client, release) {
+ if (err) {
+ winston.error('NodeBB could not connect to your PostgreSQL database. PostgreSQL returned the following error: ' + err.message);
+ return callback(err);
+ }
+
+ postgresModule.pool = db;
+ Object.defineProperty(postgresModule, 'client', {
+ get: function () {
+ return (dbNamespace.active && dbNamespace.get('db')) || db;
+ },
+ configurable: true,
+ });
+
+ var wrappedDB = {
+ connect: function () {
+ return postgresModule.pool.connect.apply(postgresModule.pool, arguments);
+ },
+ query: function () {
+ return postgresModule.client.query.apply(postgresModule.client, arguments);
+ },
+ };
+
+ checkUpgrade(client, function (err) {
+ release();
+ if (err) {
+ return callback(err);
+ }
+
+ require('./postgres/main')(wrappedDB, postgresModule);
+ require('./postgres/hash')(wrappedDB, postgresModule);
+ require('./postgres/sets')(wrappedDB, postgresModule);
+ require('./postgres/sorted')(wrappedDB, postgresModule);
+ require('./postgres/list')(wrappedDB, postgresModule);
+ require('./postgres/transaction')(db, dbNamespace, postgresModule);
+
+ postgresModule.async = require('../promisify')(postgresModule, ['client', 'sessionStore', 'pool']);
+
+ callback();
+ });
+ });
+};
+
+function checkUpgrade(client, callback) {
+ client.query(`
+SELECT EXISTS(SELECT *
+ FROM "information_schema"."columns"
+ WHERE "table_schema" = 'public'
+ AND "table_name" = 'objects'
+ AND "column_name" = 'data') a,
+ EXISTS(SELECT *
+ FROM "information_schema"."columns"
+ WHERE "table_schema" = 'public'
+ AND "table_name" = 'legacy_hash'
+ AND "column_name" = '_key') b`, function (err, res) {
+ if (err) {
+ return callback(err);
+ }
+
+ if (res.rows[0].b) {
+ return callback(null);
+ }
+
+ var query = client.query.bind(client);
+
+ async.series([
+ async.apply(query, `BEGIN`),
+ async.apply(query, `
+CREATE TYPE LEGACY_OBJECT_TYPE AS ENUM (
+ 'hash', 'zset', 'set', 'list', 'string'
+)`),
+ async.apply(query, `
+CREATE TABLE "legacy_object" (
+ "_key" TEXT NOT NULL
+ PRIMARY KEY,
+ "type" LEGACY_OBJECT_TYPE NOT NULL,
+ "expireAt" TIMESTAMPTZ DEFAULT NULL,
+ UNIQUE ( "_key", "type" )
+)`),
+ async.apply(query, `
+CREATE TABLE "legacy_hash" (
+ "_key" TEXT NOT NULL
+ PRIMARY KEY,
+ "data" JSONB NOT NULL,
+ "type" LEGACY_OBJECT_TYPE NOT NULL
+ DEFAULT 'hash'::LEGACY_OBJECT_TYPE
+ CHECK ( "type" = 'hash' ),
+ CONSTRAINT "fk__legacy_hash__key"
+ FOREIGN KEY ("_key", "type")
+ REFERENCES "legacy_object"("_key", "type")
+ ON UPDATE CASCADE
+ ON DELETE CASCADE
+)`),
+ async.apply(query, `
+CREATE TABLE "legacy_zset" (
+ "_key" TEXT NOT NULL,
+ "value" TEXT NOT NULL,
+ "score" NUMERIC NOT NULL,
+ "type" LEGACY_OBJECT_TYPE NOT NULL
+ DEFAULT 'zset'::LEGACY_OBJECT_TYPE
+ CHECK ( "type" = 'zset' ),
+ PRIMARY KEY ("_key", "value"),
+ CONSTRAINT "fk__legacy_zset__key"
+ FOREIGN KEY ("_key", "type")
+ REFERENCES "legacy_object"("_key", "type")
+ ON UPDATE CASCADE
+ ON DELETE CASCADE
+)`),
+ async.apply(query, `
+CREATE TABLE "legacy_set" (
+ "_key" TEXT NOT NULL,
+ "member" TEXT NOT NULL,
+ "type" LEGACY_OBJECT_TYPE NOT NULL
+ DEFAULT 'set'::LEGACY_OBJECT_TYPE
+ CHECK ( "type" = 'set' ),
+ PRIMARY KEY ("_key", "member"),
+ CONSTRAINT "fk__legacy_set__key"
+ FOREIGN KEY ("_key", "type")
+ REFERENCES "legacy_object"("_key", "type")
+ ON UPDATE CASCADE
+ ON DELETE CASCADE
+)`),
+ async.apply(query, `
+CREATE TABLE "legacy_list" (
+ "_key" TEXT NOT NULL
+ PRIMARY KEY,
+ "array" TEXT[] NOT NULL,
+ "type" LEGACY_OBJECT_TYPE NOT NULL
+ DEFAULT 'list'::LEGACY_OBJECT_TYPE
+ CHECK ( "type" = 'list' ),
+ CONSTRAINT "fk__legacy_list__key"
+ FOREIGN KEY ("_key", "type")
+ REFERENCES "legacy_object"("_key", "type")
+ ON UPDATE CASCADE
+ ON DELETE CASCADE
+)`),
+ async.apply(query, `
+CREATE TABLE "legacy_string" (
+ "_key" TEXT NOT NULL
+ PRIMARY KEY,
+ "data" TEXT NOT NULL,
+ "type" LEGACY_OBJECT_TYPE NOT NULL
+ DEFAULT 'string'::LEGACY_OBJECT_TYPE
+ CHECK ( "type" = 'string' ),
+ CONSTRAINT "fk__legacy_string__key"
+ FOREIGN KEY ("_key", "type")
+ REFERENCES "legacy_object"("_key", "type")
+ ON UPDATE CASCADE
+ ON DELETE CASCADE
+)`),
+ function (next) {
+ if (!res.rows[0].a) {
+ return next();
+ }
+ async.series([
+ async.apply(query, `
+INSERT INTO "legacy_object" ("_key", "type", "expireAt")
+SELECT DISTINCT "data"->>'_key',
+ CASE WHEN (SELECT COUNT(*)
+ FROM jsonb_object_keys("data" - 'expireAt')) = 2
+ THEN CASE WHEN ("data" ? 'value')
+ OR ("data" ? 'data')
+ THEN 'string'
+ WHEN "data" ? 'array'
+ THEN 'list'
+ WHEN "data" ? 'members'
+ THEN 'set'
+ ELSE 'hash'
+ END
+ WHEN (SELECT COUNT(*)
+ FROM jsonb_object_keys("data" - 'expireAt')) = 3
+ THEN CASE WHEN ("data" ? 'value')
+ AND ("data" ? 'score')
+ THEN 'zset'
+ ELSE 'hash'
+ END
+ ELSE 'hash'
+ END::LEGACY_OBJECT_TYPE,
+ CASE WHEN ("data" ? 'expireAt')
+ THEN to_timestamp(("data"->>'expireAt')::double precision / 1000)
+ ELSE NULL
+ END
+ FROM "objects"`),
+ async.apply(query, `
+INSERT INTO "legacy_hash" ("_key", "data")
+SELECT "data"->>'_key',
+ "data" - '_key' - 'expireAt'
+ FROM "objects"
+ WHERE CASE WHEN (SELECT COUNT(*)
+ FROM jsonb_object_keys("data" - 'expireAt')) = 2
+ THEN NOT (("data" ? 'value')
+ OR ("data" ? 'data')
+ OR ("data" ? 'members')
+ OR ("data" ? 'array'))
+ WHEN (SELECT COUNT(*)
+ FROM jsonb_object_keys("data" - 'expireAt')) = 3
+ THEN NOT (("data" ? 'value')
+ AND ("data" ? 'score'))
+ ELSE TRUE
+ END`),
+ async.apply(query, `
+INSERT INTO "legacy_zset" ("_key", "value", "score")
+SELECT "data"->>'_key',
+ "data"->>'value',
+ ("data"->>'score')::NUMERIC
+ FROM "objects"
+ WHERE (SELECT COUNT(*)
+ FROM jsonb_object_keys("data" - 'expireAt')) = 3
+ AND ("data" ? 'value')
+ AND ("data" ? 'score')`),
+ async.apply(query, `
+INSERT INTO "legacy_set" ("_key", "member")
+SELECT "data"->>'_key',
+ jsonb_array_elements_text("data"->'members')
+ FROM "objects"
+ WHERE (SELECT COUNT(*)
+ FROM jsonb_object_keys("data" - 'expireAt')) = 2
+ AND ("data" ? 'members')`),
+ async.apply(query, `
+INSERT INTO "legacy_list" ("_key", "array")
+SELECT "data"->>'_key',
+ ARRAY(SELECT t
+ FROM jsonb_array_elements_text("data"->'list') WITH ORDINALITY l(t, i)
+ ORDER BY i ASC)
+ FROM "objects"
+ WHERE (SELECT COUNT(*)
+ FROM jsonb_object_keys("data" - 'expireAt')) = 2
+ AND ("data" ? 'array')`),
+ async.apply(query, `
+INSERT INTO "legacy_string" ("_key", "data")
+SELECT "data"->>'_key',
+ CASE WHEN "data" ? 'value'
+ THEN "data"->>'value'
+ ELSE "data"->>'data'
+ END
+ FROM "objects"
+ WHERE (SELECT COUNT(*)
+ FROM jsonb_object_keys("data" - 'expireAt')) = 2
+ AND (("data" ? 'value')
+ OR ("data" ? 'data'))`),
+ async.apply(query, `DROP TABLE "objects" CASCADE`),
+ async.apply(query, `DROP FUNCTION "fun__objects__expireAt"() CASCADE`),
+ ], next);
+ },
+ async.apply(query, `
+CREATE VIEW "legacy_object_live" AS
+SELECT "_key", "type"
+ FROM "legacy_object"
+ WHERE "expireAt" IS NULL
+ OR "expireAt" > CURRENT_TIMESTAMP`),
+ ], function (err) {
+ query(err ? `ROLLBACK` : `COMMIT`, function (err1) {
+ callback(err1 || err);
+ });
+ });
+ });
+}
+
+postgresModule.initSessionStore = function (callback) {
+ var meta = require('../meta');
+ var sessionStore;
+
+ var ttl = meta.getSessionTTLSeconds();
+
+ if (nconf.get('redis')) {
+ sessionStore = require('connect-redis')(session);
+ var rdb = require('./redis');
+ rdb.client = rdb.connect();
+
+ postgresModule.sessionStore = new sessionStore({
+ client: rdb.client,
+ ttl: ttl,
+ });
+
+ return callback();
+ }
+
+ function done() {
+ sessionStore = require('connect-pg-simple')(session);
+ postgresModule.sessionStore = new sessionStore({
+ pool: db,
+ ttl: ttl,
+ pruneSessionInterval: nconf.get('isPrimary') === 'true' ? 60 : false,
+ });
+
+ callback();
+ }
+
+ if (nconf.get('isPrimary') !== 'true') {
+ return done();
+ }
+
+ db.query(`
+CREATE TABLE IF NOT EXISTS "session" (
+ "sid" CHAR(32) NOT NULL
+ COLLATE "C"
+ PRIMARY KEY,
+ "sess" JSONB NOT NULL,
+ "expire" TIMESTAMPTZ NOT NULL
+) WITHOUT OIDS;
+
+CREATE INDEX IF NOT EXISTS "session_expire_idx" ON "session"("expire");
+
+ALTER TABLE "session"
+ ALTER "sid" SET STORAGE MAIN,
+ CLUSTER ON "session_expire_idx";`, function (err) {
+ if (err) {
+ return callback(err);
+ }
+
+ done();
+ });
+};
+
+postgresModule.createIndices = function (callback) {
+ if (!postgresModule.pool) {
+ winston.warn('[database/createIndices] database not initialized');
+ return callback();
+ }
+
+ var query = postgresModule.pool.query.bind(postgresModule.pool);
+
+ winston.info('[database] Checking database indices.');
+ async.series([
+ async.apply(query, `CREATE INDEX IF NOT EXISTS "idx__legacy_zset__key__score" ON "legacy_zset"("_key" ASC, "score" DESC)`),
+ async.apply(query, `CREATE INDEX IF NOT EXISTS "idx__legacy_object__expireAt" ON "legacy_object"("expireAt" ASC)`),
+ ], function (err) {
+ if (err) {
+ winston.error('Error creating index ' + err.message);
+ return callback(err);
+ }
+ winston.info('[database] Checking database indices done!');
+ callback();
+ });
+};
+
+postgresModule.checkCompatibility = function (callback) {
+ var postgresPkg = require('pg/package.json');
+ postgresModule.checkCompatibilityVersion(postgresPkg.version, callback);
+};
+
+postgresModule.checkCompatibilityVersion = function (version, callback) {
+ if (semver.lt(version, '7.0.0')) {
+ return callback(new Error('The `pg` package is out-of-date, please run `./nodebb setup` again.'));
+ }
+
+ callback();
+};
+
+postgresModule.info = function (db, callback) {
+ if (!db) {
+ return callback();
+ }
+
+ db.query(`
+SELECT true "postgres",
+ current_setting('server_version') "version",
+ EXTRACT(EPOCH FROM NOW() - pg_postmaster_start_time()) * 1000 "uptime"`, function (err, res) {
+ if (err) {
+ return callback(err);
+ }
+ callback(null, res.rows[0]);
+ });
+};
+
+postgresModule.close = function (callback) {
+ callback = callback || function () {};
+ db.end(callback);
+};
+
+postgresModule.socketAdapter = function () {
+ var postgresAdapter = require('socket.io-adapter-postgres');
+ return postgresAdapter(postgresModule.getConnectionOptions(), {
+ pubClient: postgresModule.pool,
+ });
+};
diff --git a/src/database/postgres/hash.js b/src/database/postgres/hash.js
new file mode 100644
index 0000000000..aeb794185b
--- /dev/null
+++ b/src/database/postgres/hash.js
@@ -0,0 +1,391 @@
+'use strict';
+
+var async = require('async');
+
+module.exports = function (db, module) {
+ var helpers = module.helpers.postgres;
+
+ module.setObject = function (key, data, callback) {
+ callback = callback || helpers.noop;
+
+ if (!key || !data) {
+ return callback();
+ }
+
+ if (data.hasOwnProperty('')) {
+ delete data[''];
+ }
+
+ module.transaction(function (tx, done) {
+ var query = tx.client.query.bind(tx.client);
+
+ async.series([
+ async.apply(helpers.ensureLegacyObjectType, tx.client, key, 'hash'),
+ async.apply(query, {
+ name: 'setObject',
+ text: `
+INSERT INTO "legacy_hash" ("_key", "data")
+VALUES ($1::TEXT, $2::TEXT::JSONB)
+ ON CONFLICT ("_key")
+ DO UPDATE SET "data" = "legacy_hash"."data" || $2::TEXT::JSONB`,
+ values: [key, JSON.stringify(data)],
+ }),
+ ], function (err) {
+ done(err);
+ });
+ }, callback);
+ };
+
+ module.setObjectField = function (key, field, value, callback) {
+ callback = callback || helpers.noop;
+
+ if (!field) {
+ return callback();
+ }
+
+ module.transaction(function (tx, done) {
+ var query = tx.client.query.bind(tx.client);
+
+ async.series([
+ async.apply(helpers.ensureLegacyObjectType, tx.client, key, 'hash'),
+ async.apply(query, {
+ name: 'setObjectField',
+ text: `
+INSERT INTO "legacy_hash" ("_key", "data")
+VALUES ($1::TEXT, jsonb_build_object($2::TEXT, $3::TEXT::JSONB))
+ ON CONFLICT ("_key")
+ DO UPDATE SET "data" = jsonb_set("legacy_hash"."data", ARRAY[$2::TEXT], $3::TEXT::JSONB)`,
+ values: [key, field, JSON.stringify(value)],
+ }),
+ ], function (err) {
+ done(err);
+ });
+ }, callback);
+ };
+
+ module.getObject = function (key, callback) {
+ if (!key) {
+ return callback();
+ }
+
+ db.query({
+ name: 'getObject',
+ text: `
+SELECT h."data"
+ FROM "legacy_object_live" o
+ INNER JOIN "legacy_hash" h
+ ON o."_key" = h."_key"
+ AND o."type" = h."type"
+ WHERE o."_key" = $1::TEXT
+ LIMIT 1`,
+ values: [key],
+ }, function (err, res) {
+ if (err) {
+ return callback(err);
+ }
+
+ if (res.rows.length) {
+ return callback(null, res.rows[0].data);
+ }
+
+ callback(null, null);
+ });
+ };
+
+ module.getObjects = function (keys, callback) {
+ if (!Array.isArray(keys) || !keys.length) {
+ return callback(null, []);
+ }
+
+ db.query({
+ name: 'getObjects',
+ text: `
+SELECT h."data"
+ FROM UNNEST($1::TEXT[]) WITH ORDINALITY k("_key", i)
+ LEFT OUTER JOIN "legacy_object_live" o
+ ON o."_key" = k."_key"
+ LEFT OUTER JOIN "legacy_hash" h
+ ON o."_key" = h."_key"
+ AND o."type" = h."type"
+ ORDER BY k.i ASC`,
+ values: [keys],
+ }, function (err, res) {
+ if (err) {
+ return callback(err);
+ }
+
+ callback(null, res.rows.map(function (row) {
+ return row.data;
+ }));
+ });
+ };
+
+ module.getObjectField = function (key, field, callback) {
+ if (!key) {
+ return callback();
+ }
+
+ db.query({
+ name: 'getObjectField',
+ text: `
+SELECT h."data"->>$2::TEXT f
+ FROM "legacy_object_live" o
+ INNER JOIN "legacy_hash" h
+ ON o."_key" = h."_key"
+ AND o."type" = h."type"
+ WHERE o."_key" = $1::TEXT
+ LIMIT 1`,
+ values: [key, field],
+ }, function (err, res) {
+ if (err) {
+ return callback(err);
+ }
+
+ if (res.rows.length) {
+ return callback(null, res.rows[0].f);
+ }
+
+ callback(null, null);
+ });
+ };
+
+ module.getObjectFields = function (key, fields, callback) {
+ if (!key) {
+ return callback();
+ }
+
+ db.query({
+ name: 'getObjectFields',
+ text: `
+SELECT (SELECT jsonb_object_agg(f, d."value")
+ FROM UNNEST($2::TEXT[]) f
+ LEFT OUTER JOIN jsonb_each(h."data") d
+ ON d."key" = f) d
+ FROM "legacy_object_live" o
+ INNER JOIN "legacy_hash" h
+ ON o."_key" = h."_key"
+ AND o."type" = h."type"
+ WHERE o."_key" = $1::TEXT`,
+ values: [key, fields],
+ }, function (err, res) {
+ if (err) {
+ return callback(err);
+ }
+
+ if (res.rows.length) {
+ return callback(null, res.rows[0].d);
+ }
+
+ var obj = {};
+ fields.forEach(function (f) {
+ obj[f] = null;
+ });
+
+ callback(null, obj);
+ });
+ };
+
+ module.getObjectsFields = function (keys, fields, callback) {
+ if (!Array.isArray(keys) || !keys.length) {
+ return callback(null, []);
+ }
+
+ db.query({
+ name: 'getObjectsFields',
+ text: `
+SELECT (SELECT jsonb_object_agg(f, d."value")
+ FROM UNNEST($2::TEXT[]) f
+ LEFT OUTER JOIN jsonb_each(h."data") d
+ ON d."key" = f) d
+ FROM UNNEST($1::text[]) WITH ORDINALITY k("_key", i)
+ LEFT OUTER JOIN "legacy_object_live" o
+ ON o."_key" = k."_key"
+ LEFT OUTER JOIN "legacy_hash" h
+ ON o."_key" = h."_key"
+ AND o."type" = h."type"
+ ORDER BY k.i ASC`,
+ values: [keys, fields],
+ }, function (err, res) {
+ if (err) {
+ return callback(err);
+ }
+
+ callback(null, res.rows.map(function (row) {
+ return row.d;
+ }));
+ });
+ };
+
+ module.getObjectKeys = function (key, callback) {
+ if (!key) {
+ return callback();
+ }
+
+ db.query({
+ name: 'getObjectKeys',
+ text: `
+SELECT ARRAY(SELECT jsonb_object_keys(h."data")) k
+ FROM "legacy_object_live" o
+ INNER JOIN "legacy_hash" h
+ ON o."_key" = h."_key"
+ AND o."type" = h."type"
+ WHERE o."_key" = $1::TEXT
+ LIMIT 1`,
+ values: [key],
+ }, function (err, res) {
+ if (err) {
+ return callback(err);
+ }
+
+ if (res.rows.length) {
+ return callback(null, res.rows[0].k);
+ }
+
+ callback(null, []);
+ });
+ };
+
+ module.getObjectValues = function (key, callback) {
+ module.getObject(key, function (err, data) {
+ if (err) {
+ return callback(err);
+ }
+
+ var values = [];
+
+ if (data) {
+ for (var key in data) {
+ if (data.hasOwnProperty(key)) {
+ values.push(data[key]);
+ }
+ }
+ }
+
+ callback(null, values);
+ });
+ };
+
+ module.isObjectField = function (key, field, callback) {
+ if (!key) {
+ return callback();
+ }
+
+ db.query({
+ name: 'isObjectField',
+ text: `
+SELECT (h."data" ? $2::TEXT AND h."data"->>$2::TEXT IS NOT NULL) b
+ FROM "legacy_object_live" o
+ INNER JOIN "legacy_hash" h
+ ON o."_key" = h."_key"
+ AND o."type" = h."type"
+ WHERE o."_key" = $1::TEXT
+ LIMIT 1`,
+ values: [key, field],
+ }, function (err, res) {
+ if (err) {
+ return callback(err);
+ }
+
+ if (res.rows.length) {
+ return callback(null, res.rows[0].b);
+ }
+
+ callback(null, false);
+ });
+ };
+
+ module.isObjectFields = function (key, fields, callback) {
+ if (!key) {
+ return callback();
+ }
+
+ module.getObjectFields(key, fields, function (err, data) {
+ if (err) {
+ return callback(err);
+ }
+
+ if (!data) {
+ return callback(null, fields.map(function () {
+ return false;
+ }));
+ }
+
+ callback(null, fields.map(function (field) {
+ return data.hasOwnProperty(field) && data[field] !== null;
+ }));
+ });
+ };
+
+ module.deleteObjectField = function (key, field, callback) {
+ module.deleteObjectFields(key, [field], callback);
+ };
+
+ module.deleteObjectFields = function (key, fields, callback) {
+ callback = callback || helpers.noop;
+ if (!key || !Array.isArray(fields) || !fields.length) {
+ return callback();
+ }
+
+ db.query({
+ name: 'deleteObjectFields',
+ text: `
+UPDATE "legacy_hash"
+ SET "data" = COALESCE((SELECT jsonb_object_agg("key", "value")
+ FROM jsonb_each("data")
+ WHERE "key" <> ALL ($2::TEXT[])), '{}')
+ WHERE "_key" = $1::TEXT`,
+ values: [key, fields],
+ }, function (err) {
+ callback(err);
+ });
+ };
+
+ module.incrObjectField = function (key, field, callback) {
+ module.incrObjectFieldBy(key, field, 1, callback);
+ };
+
+ module.decrObjectField = function (key, field, callback) {
+ module.incrObjectFieldBy(key, field, -1, callback);
+ };
+
+ module.incrObjectFieldBy = function (key, field, value, callback) {
+ callback = callback || helpers.noop;
+ value = parseInt(value, 10);
+
+ if (!key || isNaN(value)) {
+ return callback(null, null);
+ }
+
+ module.transaction(function (tx, done) {
+ var query = tx.client.query.bind(tx.client);
+
+ async.waterfall([
+ async.apply(Array.isArray(key) ? helpers.ensureLegacyObjectsType : helpers.ensureLegacyObjectType, tx.client, key, 'hash'),
+ async.apply(query, Array.isArray(key) ? {
+ name: 'incrObjectFieldByMulti',
+ text: `
+INSERT INTO "legacy_hash" ("_key", "data")
+SELECT UNNEST($1::TEXT[]), jsonb_build_object($2::TEXT, $3::NUMERIC)
+ ON CONFLICT ("_key")
+ DO UPDATE SET "data" = jsonb_set("legacy_hash"."data", ARRAY[$2::TEXT], to_jsonb(COALESCE(("legacy_hash"."data"->>$2::TEXT)::NUMERIC, 0) + $3::NUMERIC))
+RETURNING ("data"->>$2::TEXT)::NUMERIC v`,
+ values: [key, field, value],
+ } : {
+ name: 'incrObjectFieldBy',
+ text: `
+INSERT INTO "legacy_hash" ("_key", "data")
+VALUES ($1::TEXT, jsonb_build_object($2::TEXT, $3::NUMERIC))
+ ON CONFLICT ("_key")
+ DO UPDATE SET "data" = jsonb_set("legacy_hash"."data", ARRAY[$2::TEXT], to_jsonb(COALESCE(("legacy_hash"."data"->>$2::TEXT)::NUMERIC, 0) + $3::NUMERIC))
+RETURNING ("data"->>$2::TEXT)::NUMERIC v`,
+ values: [key, field, value],
+ }),
+ function (res, next) {
+ next(null, Array.isArray(key) ? res.rows.map(function (r) {
+ return parseFloat(r.v);
+ }) : parseFloat(res.rows[0].v));
+ },
+ ], done);
+ }, callback);
+ };
+};
diff --git a/src/database/postgres/helpers.js b/src/database/postgres/helpers.js
new file mode 100644
index 0000000000..1b9a4d2cf6
--- /dev/null
+++ b/src/database/postgres/helpers.js
@@ -0,0 +1,139 @@
+'use strict';
+
+var helpers = {};
+
+helpers.valueToString = function (value) {
+ if (value === null || value === undefined) {
+ return value;
+ }
+
+ return value.toString();
+};
+
+helpers.removeDuplicateValues = function (values) {
+ var others = Array.prototype.slice.call(arguments, 1);
+ for (var i = 0; i < values.length; i++) {
+ if (values.lastIndexOf(values[i]) !== i) {
+ values.splice(i, 1);
+ for (var j = 0; j < others.length; j++) {
+ others[j].splice(i, 1);
+ }
+ i -= 1;
+ }
+ }
+};
+
+helpers.ensureLegacyObjectType = function (db, key, type, callback) {
+ db.query({
+ name: 'ensureLegacyObjectTypeBefore',
+ text: `
+DELETE FROM "legacy_object"
+ WHERE "expireAt" IS NOT NULL
+ AND "expireAt" <= CURRENT_TIMESTAMP`,
+ }, function (err) {
+ if (err) {
+ return callback(err);
+ }
+
+ db.query({
+ name: 'ensureLegacyObjectType1',
+ text: `
+INSERT INTO "legacy_object" ("_key", "type")
+VALUES ($1::TEXT, $2::TEXT::LEGACY_OBJECT_TYPE)
+ ON CONFLICT
+ DO NOTHING`,
+ values: [key, type],
+ }, function (err) {
+ if (err) {
+ return callback(err);
+ }
+
+ db.query({
+ name: 'ensureLegacyObjectType2',
+ text: `
+SELECT "type"
+ FROM "legacy_object_live"
+ WHERE "_key" = $1::TEXT`,
+ values: [key],
+ }, function (err, res) {
+ if (err) {
+ return callback(err);
+ }
+
+ if (res.rows[0].type !== type) {
+ return callback(new Error('database: cannot insert ' + JSON.stringify(key) + ' as ' + type + ' because it already exists as ' + res.rows[0].type));
+ }
+
+ callback(null);
+ });
+ });
+ });
+};
+
+helpers.ensureLegacyObjectsType = function (db, keys, type, callback) {
+ db.query({
+ name: 'ensureLegacyObjectTypeBefore',
+ text: `
+DELETE FROM "legacy_object"
+ WHERE "expireAt" IS NOT NULL
+ AND "expireAt" <= CURRENT_TIMESTAMP`,
+ }, function (err) {
+ if (err) {
+ return callback(err);
+ }
+
+ db.query({
+ name: 'ensureLegacyObjectsType1',
+ text: `
+INSERT INTO "legacy_object" ("_key", "type")
+SELECT k, $2::TEXT::LEGACY_OBJECT_TYPE
+ FROM UNNEST($1::TEXT[]) k
+ ON CONFLICT
+ DO NOTHING`,
+ values: [keys, type],
+ }, function (err) {
+ if (err) {
+ return callback(err);
+ }
+
+ db.query({
+ name: 'ensureLegacyObjectsType2',
+ text: `
+SELECT "_key", "type"
+ FROM "legacy_object_live"
+ WHERE "_key" = ANY($1::TEXT[])`,
+ values: [keys],
+ }, function (err, res) {
+ if (err) {
+ return callback(err);
+ }
+
+ var invalid = res.rows.filter(function (r) {
+ return r.type !== type;
+ });
+
+ if (invalid.length) {
+ return callback(new Error('database: cannot insert multiple objects as ' + type + ' because they already exist: ' + invalid.map(function (r) {
+ return JSON.stringify(r._key) + ' is ' + r.type;
+ }).join(', ')));
+ }
+
+ var missing = keys.filter(function (k) {
+ return !res.rows.some(function (r) {
+ return r._key === k;
+ });
+ });
+
+ if (missing.length) {
+ return callback(new Error('database: failed to insert keys for objects: ' + JSON.stringify(missing)));
+ }
+
+ callback(null);
+ });
+ });
+ });
+};
+
+helpers.noop = function () {};
+
+module.exports = helpers;
diff --git a/src/database/postgres/list.js b/src/database/postgres/list.js
new file mode 100644
index 0000000000..9d11e16dff
--- /dev/null
+++ b/src/database/postgres/list.js
@@ -0,0 +1,234 @@
+'use strict';
+
+var async = require('async');
+
+module.exports = function (db, module) {
+ var helpers = module.helpers.postgres;
+
+ module.listPrepend = function (key, value, callback) {
+ callback = callback || helpers.noop;
+
+ if (!key) {
+ return callback();
+ }
+
+ module.transaction(function (tx, done) {
+ var query = tx.client.query.bind(tx.client);
+
+ async.series([
+ async.apply(helpers.ensureLegacyObjectType, tx.client, key, 'list'),
+ async.apply(query, {
+ name: 'listPrepend',
+ text: `
+INSERT INTO "legacy_list" ("_key", "array")
+VALUES ($1::TEXT, ARRAY[$2::TEXT])
+ ON CONFLICT ("_key")
+ DO UPDATE SET "array" = ARRAY[$2::TEXT] || "legacy_list"."array"`,
+ values: [key, value],
+ }),
+ ], function (err) {
+ done(err);
+ });
+ }, callback);
+ };
+
+ module.listAppend = function (key, value, callback) {
+ callback = callback || helpers.noop;
+
+ if (!key) {
+ return callback();
+ }
+
+ module.transaction(function (tx, done) {
+ var query = tx.client.query.bind(tx.client);
+
+ async.series([
+ async.apply(helpers.ensureLegacyObjectType, tx.client, key, 'list'),
+ async.apply(query, {
+ name: 'listAppend',
+ text: `
+INSERT INTO "legacy_list" ("_key", "array")
+VALUES ($1::TEXT, ARRAY[$2::TEXT])
+ ON CONFLICT ("_key")
+ DO UPDATE SET "array" = "legacy_list"."array" || ARRAY[$2::TEXT]`,
+ values: [key, value],
+ }),
+ ], function (err) {
+ done(err);
+ });
+ }, callback || helpers.noop);
+ };
+
+ module.listRemoveLast = function (key, callback) {
+ callback = callback || helpers.noop;
+
+ if (!key) {
+ return callback();
+ }
+
+ db.query({
+ name: 'listRemoveLast',
+ text: `
+WITH A AS (
+ SELECT l.*
+ FROM "legacy_object_live" o
+ INNER JOIN "legacy_list" l
+ ON o."_key" = l."_key"
+ AND o."type" = l."type"
+ WHERE o."_key" = $1::TEXT
+ FOR UPDATE)
+UPDATE "legacy_list" l
+ SET "array" = A."array"[1 : array_length(A."array", 1) - 1]
+ FROM A
+ WHERE A."_key" = l."_key"
+RETURNING A."array"[array_length(A."array", 1)] v`,
+ values: [key],
+ }, function (err, res) {
+ if (err) {
+ return callback(err);
+ }
+
+ if (res.rows.length) {
+ return callback(null, res.rows[0].v);
+ }
+
+ callback(null, null);
+ });
+ };
+
+ module.listRemoveAll = function (key, value, callback) {
+ callback = callback || helpers.noop;
+
+ if (!key) {
+ return callback();
+ }
+
+ db.query({
+ name: 'listRemoveAll',
+ text: `
+UPDATE "legacy_list" l
+ SET "array" = array_remove(l."array", $2::TEXT)
+ FROM "legacy_object_live" o
+ WHERE o."_key" = l."_key"
+ AND o."type" = l."type"
+ AND o."_key" = $1::TEXT`,
+ values: [key, value],
+ }, function (err) {
+ callback(err);
+ });
+ };
+
+ module.listTrim = function (key, start, stop, callback) {
+ callback = callback || helpers.noop;
+
+ if (!key) {
+ return callback();
+ }
+
+ stop += 1;
+
+ db.query(stop > 0 ? {
+ name: 'listTrim',
+ text: `
+UPDATE "legacy_list" l
+ SET "array" = ARRAY(SELECT m.m
+ FROM UNNEST(l."array") WITH ORDINALITY m(m, i)
+ ORDER BY m.i ASC
+ LIMIT ($3::INTEGER - $2::INTEGER)
+ OFFSET $2::INTEGER)
+ FROM "legacy_object_live" o
+ WHERE o."_key" = l."_key"
+ AND o."type" = l."type"
+ AND o."_key" = $1::TEXT`,
+ values: [key, start, stop],
+ } : {
+ name: 'listTrimBack',
+ text: `
+UPDATE "legacy_list" l
+ SET "array" = ARRAY(SELECT m.m
+ FROM UNNEST(l."array") WITH ORDINALITY m(m, i)
+ ORDER BY m.i ASC
+ LIMIT ($3::INTEGER - $2::INTEGER + array_length(l."array", 1))
+ OFFSET $2::INTEGER)
+ FROM "legacy_object_live" o
+ WHERE o."_key" = l."_key"
+ AND o."type" = l."type"
+ AND o."_key" = $1::TEXT`,
+ values: [key, start, stop],
+ }, function (err) {
+ callback(err);
+ });
+ };
+
+ module.getListRange = function (key, start, stop, callback) {
+ if (!key) {
+ return callback();
+ }
+
+ stop += 1;
+
+ db.query(stop > 0 ? {
+ name: 'getListRange',
+ text: `
+SELECT ARRAY(SELECT m.m
+ FROM UNNEST(l."array") WITH ORDINALITY m(m, i)
+ ORDER BY m.i ASC
+ LIMIT ($3::INTEGER - $2::INTEGER)
+ OFFSET $2::INTEGER) l
+ FROM "legacy_object_live" o
+ INNER JOIN "legacy_list" l
+ ON o."_key" = l."_key"
+ AND o."type" = l."type"
+ WHERE o."_key" = $1::TEXT`,
+ values: [key, start, stop],
+ } : {
+ name: 'getListRangeBack',
+ text: `
+SELECT ARRAY(SELECT m.m
+ FROM UNNEST(l."array") WITH ORDINALITY m(m, i)
+ ORDER BY m.i ASC
+ LIMIT ($3::INTEGER - $2::INTEGER + array_length(l."array", 1))
+ OFFSET $2::INTEGER) l
+ FROM "legacy_object_live" o
+ INNER JOIN "legacy_list" l
+ ON o."_key" = l."_key"
+ AND o."type" = l."type"
+ WHERE o."_key" = $1::TEXT`,
+ values: [key, start, stop],
+ }, function (err, res) {
+ if (err) {
+ return callback(err);
+ }
+
+ if (res.rows.length) {
+ return callback(null, res.rows[0].l);
+ }
+
+ callback(null, []);
+ });
+ };
+
+ module.listLength = function (key, callback) {
+ db.query({
+ name: 'listLength',
+ text: `
+SELECT array_length(l."array", 1) l
+ FROM "legacy_object_live" o
+ INNER JOIN "legacy_list" l
+ ON o."_key" = l."_key"
+ AND o."type" = l."type"
+ WHERE o."_key" = $1::TEXT`,
+ values: [key],
+ }, function (err, res) {
+ if (err) {
+ return callback(err);
+ }
+
+ if (res.rows.length) {
+ return callback(null, res.rows[0].l);
+ }
+
+ callback(null, 0);
+ });
+ };
+};
diff --git a/src/database/postgres/main.js b/src/database/postgres/main.js
new file mode 100644
index 0000000000..2dcc6ecd5e
--- /dev/null
+++ b/src/database/postgres/main.js
@@ -0,0 +1,239 @@
+'use strict';
+
+var async = require('async');
+
+module.exports = function (db, module) {
+ var helpers = module.helpers.postgres;
+
+ var query = db.query.bind(db);
+
+ module.flushdb = function (callback) {
+ callback = callback || helpers.noop;
+
+ async.series([
+ async.apply(query, `DROP SCHEMA "public" CASCADE`),
+ async.apply(query, `CREATE SCHEMA "public"`),
+ ], function (err) {
+ callback(err);
+ });
+ };
+
+ module.emptydb = function (callback) {
+ callback = callback || helpers.noop;
+ query(`DELETE FROM "legacy_object"`, function (err) {
+ callback(err);
+ });
+ };
+
+ module.exists = function (key, callback) {
+ if (!key) {
+ return callback();
+ }
+
+ query({
+ name: 'exists',
+ text: `
+SELECT EXISTS(SELECT *
+ FROM "legacy_object_live"
+ WHERE "_key" = $1::TEXT
+ LIMIT 1) e`,
+ values: [key],
+ }, function (err, res) {
+ if (err) {
+ return callback(err);
+ }
+
+ callback(null, res.rows[0].e);
+ });
+ };
+
+ module.delete = function (key, callback) {
+ callback = callback || helpers.noop;
+ if (!key) {
+ return callback();
+ }
+
+ query({
+ name: 'delete',
+ text: `
+DELETE FROM "legacy_object"
+ WHERE "_key" = $1::TEXT`,
+ values: [key],
+ }, function (err) {
+ callback(err);
+ });
+ };
+
+ module.deleteAll = function (keys, callback) {
+ callback = callback || helpers.noop;
+
+ if (!Array.isArray(keys) || !keys.length) {
+ return callback();
+ }
+
+ query({
+ name: 'deleteAll',
+ text: `
+DELETE FROM "legacy_object"
+ WHERE "_key" = ANY($1::TEXT[])`,
+ values: [keys],
+ }, function (err) {
+ callback(err);
+ });
+ };
+
+ module.get = function (key, callback) {
+ if (!key) {
+ return callback();
+ }
+
+ query({
+ name: 'get',
+ text: `
+SELECT s."data" t
+ FROM "legacy_object_live" o
+ INNER JOIN "legacy_string" s
+ ON o."_key" = s."_key"
+ AND o."type" = s."type"
+ WHERE o."_key" = $1::TEXT
+ LIMIT 1`,
+ values: [key],
+ }, function (err, res) {
+ if (err) {
+ return callback(err);
+ }
+
+ if (res.rows.length) {
+ return callback(null, res.rows[0].t);
+ }
+
+ callback(null, null);
+ });
+ };
+
+ module.set = function (key, value, callback) {
+ callback = callback || helpers.noop;
+
+ if (!key) {
+ return callback();
+ }
+
+ module.transaction(function (tx, done) {
+ async.series([
+ async.apply(helpers.ensureLegacyObjectType, tx.client, key, 'string'),
+ async.apply(tx.client.query.bind(tx.client), {
+ name: 'set',
+ text: `
+INSERT INTO "legacy_string" ("_key", "data")
+VALUES ($1::TEXT, $2::TEXT)
+ ON CONFLICT ("_key")
+ DO UPDATE SET "data" = $2::TEXT`,
+ values: [key, value],
+ }),
+ ], function (err) {
+ done(err);
+ });
+ }, callback);
+ };
+
+ module.increment = function (key, callback) {
+ callback = callback || helpers.noop;
+
+ if (!key) {
+ return callback();
+ }
+
+ module.transaction(function (tx, done) {
+ async.waterfall([
+ async.apply(helpers.ensureLegacyObjectType, tx.client, key, 'string'),
+ async.apply(tx.client.query.bind(tx.client), {
+ name: 'increment',
+ text: `
+INSERT INTO "legacy_string" ("_key", "data")
+VALUES ($1::TEXT, '1')
+ ON CONFLICT ("_key")
+ DO UPDATE SET "data" = ("legacy_string"."data"::NUMERIC + 1)::TEXT
+RETURNING "data" d`,
+ values: [key],
+ }),
+ ], function (err, res) {
+ if (err) {
+ return done(err);
+ }
+
+ done(null, parseFloat(res.rows[0].d));
+ });
+ }, callback);
+ };
+
+ module.rename = function (oldKey, newKey, callback) {
+ module.transaction(function (tx, done) {
+ async.series([
+ async.apply(tx.delete, newKey),
+ async.apply(tx.client.query.bind(tx.client), {
+ name: 'rename',
+ text: `
+UPDATE "legacy_object"
+ SET "_key" = $2::TEXT
+ WHERE "_key" = $1::TEXT`,
+ values: [oldKey, newKey],
+ }),
+ ], function (err) {
+ done(err);
+ });
+ }, callback || helpers.noop);
+ };
+
+ module.type = function (key, callback) {
+ query({
+ name: 'type',
+ text: `
+SELECT "type"::TEXT t
+ FROM "legacy_object_live"
+ WHERE "_key" = $1::TEXT
+ LIMIT 1`,
+ values: [key],
+ }, function (err, res) {
+ if (err) {
+ return callback(err);
+ }
+
+ if (res.rows.length) {
+ return callback(null, res.rows[0].t);
+ }
+
+ callback(null, null);
+ });
+ };
+
+ function doExpire(key, date, callback) {
+ query({
+ name: 'expire',
+ text: `
+UPDATE "legacy_object"
+ SET "expireAt" = $2::TIMESTAMPTZ
+ WHERE "_key" = $1::TEXT`,
+ values: [key, date],
+ }, function (err) {
+ if (callback) {
+ callback(err);
+ }
+ });
+ }
+
+ module.expire = function (key, seconds, callback) {
+ doExpire(key, new Date(((Date.now() / 1000) + seconds) * 1000), callback);
+ };
+
+ module.expireAt = function (key, timestamp, callback) {
+ doExpire(key, new Date(timestamp * 1000), callback);
+ };
+
+ module.pexpire = function (key, ms, callback) {
+ doExpire(key, new Date(Date.now() + parseInt(ms, 10)), callback);
+ };
+
+ module.pexpireAt = function (key, timestamp, callback) {
+ doExpire(key, new Date(timestamp), callback);
+ };
+};
diff --git a/src/database/postgres/pubsub.js b/src/database/postgres/pubsub.js
new file mode 100644
index 0000000000..969ef62cb8
--- /dev/null
+++ b/src/database/postgres/pubsub.js
@@ -0,0 +1,51 @@
+'use strict';
+
+var util = require('util');
+var winston = require('winston');
+var EventEmitter = require('events').EventEmitter;
+var pg = require('pg');
+var db = require('../postgres');
+
+var PubSub = function () {
+ var self = this;
+
+ var subClient = new pg.Client(db.getConnectionOptions());
+
+ subClient.connect(function (err) {
+ if (err) {
+ winston.error(err);
+ return;
+ }
+
+ subClient.query('LISTEN pubsub', function (err) {
+ if (err) {
+ winston.error(err);
+ }
+ });
+
+ subClient.on('notification', function (message) {
+ if (message.channel !== 'pubsub') {
+ return;
+ }
+
+ try {
+ var msg = JSON.parse(message.payload);
+ self.emit(msg.event, msg.data);
+ } catch (err) {
+ winston.error(err.stack);
+ }
+ });
+ });
+};
+
+util.inherits(PubSub, EventEmitter);
+
+PubSub.prototype.publish = function (event, data) {
+ db.pool.query({
+ name: 'pubSubPublish',
+ text: `SELECT pg_notify('pubsub', $1::TEXT)`,
+ values: [JSON.stringify({ event: event, data: data })],
+ });
+};
+
+module.exports = new PubSub();
diff --git a/src/database/postgres/sets.js b/src/database/postgres/sets.js
new file mode 100644
index 0000000000..615cb6ad42
--- /dev/null
+++ b/src/database/postgres/sets.js
@@ -0,0 +1,342 @@
+'use strict';
+
+var async = require('async');
+
+module.exports = function (db, module) {
+ var helpers = module.helpers.postgres;
+
+ module.setAdd = function (key, value, callback) {
+ callback = callback || helpers.noop;
+
+ if (!Array.isArray(value)) {
+ value = [value];
+ }
+
+ module.transaction(function (tx, done) {
+ var query = tx.client.query.bind(tx.client);
+
+ async.series([
+ async.apply(helpers.ensureLegacyObjectType, tx.client, key, 'set'),
+ async.apply(query, {
+ name: 'setAdd',
+ text: `
+INSERT INTO "legacy_set" ("_key", "member")
+SELECT $1::TEXT, m
+ FROM UNNEST($2::TEXT[]) m
+ ON CONFLICT ("_key", "member")
+ DO NOTHING`,
+ values: [key, value],
+ }),
+ ], function (err) {
+ done(err);
+ });
+ }, callback);
+ };
+
+ module.setsAdd = function (keys, value, callback) {
+ callback = callback || helpers.noop;
+
+ if (!Array.isArray(keys) || !keys.length) {
+ return callback();
+ }
+
+ if (!Array.isArray(value)) {
+ value = [value];
+ }
+
+ keys = keys.filter(function (k, i, a) {
+ return a.indexOf(k) === i;
+ });
+
+ module.transaction(function (tx, done) {
+ var query = tx.client.query.bind(tx.client);
+
+ async.series([
+ async.apply(helpers.ensureLegacyObjectsType, tx.client, keys, 'set'),
+ async.apply(query, {
+ name: 'setsAdd',
+ text: `
+INSERT INTO "legacy_set" ("_key", "member")
+SELECT k, m
+ FROM UNNEST($1::TEXT[]) k
+ CROSS JOIN UNNEST($2::TEXT[]) m
+ ON CONFLICT ("_key", "member")
+ DO NOTHING`,
+ values: [keys, value],
+ }),
+ ], function (err) {
+ done(err);
+ });
+ }, callback);
+ };
+
+ module.setRemove = function (key, value, callback) {
+ callback = callback || helpers.noop;
+
+ if (!Array.isArray(key)) {
+ key = [key];
+ }
+
+ if (!Array.isArray(value)) {
+ value = [value];
+ }
+
+ db.query({
+ name: 'setRemove',
+ text: `
+DELETE FROM "legacy_set"
+ WHERE "_key" = ANY($1::TEXT[])
+ AND "member" = ANY($2::TEXT[])`,
+ values: [key, value],
+ }, function (err) {
+ callback(err);
+ });
+ };
+
+ module.setsRemove = function (keys, value, callback) {
+ callback = callback || helpers.noop;
+
+ if (!Array.isArray(keys) || !keys.length) {
+ return callback();
+ }
+
+ db.query({
+ name: 'setsRemove',
+ text: `
+DELETE FROM "legacy_set"
+ WHERE "_key" = ANY($1::TEXT[])
+ AND "member" = $2::TEXT`,
+ values: [keys, value],
+ }, function (err) {
+ callback(err);
+ });
+ };
+
+ module.isSetMember = function (key, value, callback) {
+ if (!key) {
+ return callback(null, false);
+ }
+
+ db.query({
+ name: 'isSetMember',
+ text: `
+SELECT 1
+ FROM "legacy_object_live" o
+ INNER JOIN "legacy_set" s
+ ON o."_key" = s."_key"
+ AND o."type" = s."type"
+ WHERE o."_key" = $1::TEXT
+ AND s."member" = $2::TEXT`,
+ values: [key, value],
+ }, function (err, res) {
+ if (err) {
+ return callback(err);
+ }
+
+ callback(null, !!res.rows.length);
+ });
+ };
+
+ module.isSetMembers = function (key, values, callback) {
+ if (!key || !Array.isArray(values) || !values.length) {
+ return callback(null, []);
+ }
+
+ values = values.map(helpers.valueToString);
+
+ db.query({
+ name: 'isSetMembers',
+ text: `
+SELECT s."member" m
+ FROM "legacy_object_live" o
+ INNER JOIN "legacy_set" s
+ ON o."_key" = s."_key"
+ AND o."type" = s."type"
+ WHERE o."_key" = $1::TEXT
+ AND s."member" = ANY($2::TEXT[])`,
+ values: [key, values],
+ }, function (err, res) {
+ if (err) {
+ return callback(err);
+ }
+
+ callback(null, values.map(function (v) {
+ return res.rows.some(function (r) {
+ return r.m === v;
+ });
+ }));
+ });
+ };
+
+ module.isMemberOfSets = function (sets, value, callback) {
+ if (!Array.isArray(sets) || !sets.length) {
+ return callback(null, []);
+ }
+
+ value = helpers.valueToString(value);
+
+ db.query({
+ name: 'isMemberOfSets',
+ text: `
+SELECT o."_key" k
+ FROM "legacy_object_live" o
+ INNER JOIN "legacy_set" s
+ ON o."_key" = s."_key"
+ AND o."type" = s."type"
+ WHERE o."_key" = ANY($1::TEXT[])
+ AND s."member" = $2::TEXT`,
+ values: [sets, value],
+ }, function (err, res) {
+ if (err) {
+ return callback(err);
+ }
+
+ callback(null, sets.map(function (s) {
+ return res.rows.some(function (r) {
+ return r.k === s;
+ });
+ }));
+ });
+ };
+
+ module.getSetMembers = function (key, callback) {
+ if (!key) {
+ return callback(null, []);
+ }
+
+ db.query({
+ name: 'getSetMembers',
+ text: `
+SELECT s."member" m
+ FROM "legacy_object_live" o
+ INNER JOIN "legacy_set" s
+ ON o."_key" = s."_key"
+ AND o."type" = s."type"
+ WHERE o."_key" = $1::TEXT`,
+ values: [key],
+ }, function (err, res) {
+ if (err) {
+ return callback(err);
+ }
+
+ callback(null, res.rows.map(function (r) {
+ return r.m;
+ }));
+ });
+ };
+
+ module.getSetsMembers = function (keys, callback) {
+ if (!Array.isArray(keys) || !keys.length) {
+ return callback(null, []);
+ }
+
+ db.query({
+ name: 'getSetsMembers',
+ text: `
+SELECT o."_key" k,
+ array_agg(s."member") m
+ FROM "legacy_object_live" o
+ INNER JOIN "legacy_set" s
+ ON o."_key" = s."_key"
+ AND o."type" = s."type"
+ WHERE o."_key" = ANY($1::TEXT[])
+ GROUP BY o."_key"`,
+ values: [keys],
+ }, function (err, res) {
+ if (err) {
+ return callback(err);
+ }
+
+ callback(null, keys.map(function (k) {
+ return (res.rows.find(function (r) {
+ return r.k === k;
+ }) || { m: [] }).m;
+ }));
+ });
+ };
+
+ module.setCount = function (key, callback) {
+ if (!key) {
+ return callback(null, 0);
+ }
+
+ db.query({
+ name: 'setCount',
+ text: `
+SELECT COUNT(*) c
+ FROM "legacy_object_live" o
+ INNER JOIN "legacy_set" s
+ ON o."_key" = s."_key"
+ AND o."type" = s."type"
+ WHERE o."_key" = $1::TEXT`,
+ values: [key],
+ }, function (err, res) {
+ if (err) {
+ return callback(err);
+ }
+
+ callback(null, parseInt(res.rows[0].c, 10));
+ });
+ };
+
+ module.setsCount = function (keys, callback) {
+ db.query({
+ name: 'setsCount',
+ text: `
+SELECT o."_key" k,
+ COUNT(*) c
+ FROM "legacy_object_live" o
+ INNER JOIN "legacy_set" s
+ ON o."_key" = s."_key"
+ AND o."type" = s."type"
+ WHERE o."_key" = ANY($1::TEXT[])
+ GROUP BY o."_key"`,
+ values: [keys],
+ }, function (err, res) {
+ if (err) {
+ return callback(err);
+ }
+
+ callback(null, keys.map(function (k) {
+ return (res.rows.find(function (r) {
+ return r.k === k;
+ }) || { c: 0 }).c;
+ }));
+ });
+ };
+
+ module.setRemoveRandom = function (key, callback) {
+ callback = callback || helpers.noop;
+
+ db.query({
+ name: 'setRemoveRandom',
+ text: `
+WITH A AS (
+ SELECT s."member"
+ FROM "legacy_object_live" o
+ INNER JOIN "legacy_set" s
+ ON o."_key" = s."_key"
+ AND o."type" = s."type"
+ WHERE o."_key" = $1::TEXT
+ ORDER BY RANDOM()
+ LIMIT 1
+ FOR UPDATE)
+DELETE FROM "legacy_set" s
+ USING A
+ WHERE s."_key" = $1::TEXT
+ AND s."member" = A."member"
+RETURNING A."member" m`,
+ values: [key],
+ }, function (err, res) {
+ if (err) {
+ return callback(err);
+ }
+
+ if (res.rows.length) {
+ return callback(null, res.rows[0].m);
+ }
+
+ callback(null, null);
+ });
+ };
+};
diff --git a/src/database/postgres/sorted.js b/src/database/postgres/sorted.js
new file mode 100644
index 0000000000..d540f6ca9a
--- /dev/null
+++ b/src/database/postgres/sorted.js
@@ -0,0 +1,744 @@
+'use strict';
+
+var async = require('async');
+
+module.exports = function (db, module) {
+ var helpers = module.helpers.postgres;
+
+ var query = db.query.bind(db);
+
+ require('./sorted/add')(db, module);
+ require('./sorted/remove')(db, module);
+ require('./sorted/union')(db, module);
+ require('./sorted/intersect')(db, module);
+
+ module.getSortedSetRange = function (key, start, stop, callback) {
+ getSortedSetRange(key, start, stop, 1, false, callback);
+ };
+
+ module.getSortedSetRevRange = function (key, start, stop, callback) {
+ getSortedSetRange(key, start, stop, -1, false, callback);
+ };
+
+ module.getSortedSetRangeWithScores = function (key, start, stop, callback) {
+ getSortedSetRange(key, start, stop, 1, true, callback);
+ };
+
+ module.getSortedSetRevRangeWithScores = function (key, start, stop, callback) {
+ getSortedSetRange(key, start, stop, -1, true, callback);
+ };
+
+ function getSortedSetRange(key, start, stop, sort, withScores, callback) {
+ if (!key) {
+ return callback();
+ }
+
+ if (!Array.isArray(key)) {
+ key = [key];
+ }
+
+ if (start < 0 && start > stop) {
+ return callback(null, []);
+ }
+
+ var reverse = false;
+ if (start === 0 && stop < -1) {
+ reverse = true;
+ sort *= -1;
+ start = Math.abs(stop + 1);
+ stop = -1;
+ } else if (start < 0 && stop > start) {
+ var tmp1 = Math.abs(stop + 1);
+ stop = Math.abs(start + 1);
+ start = tmp1;
+ }
+
+ var limit = stop - start + 1;
+ if (limit <= 0) {
+ limit = null;
+ }
+
+ query({
+ name: 'getSortedSetRangeWithScores' + (sort > 0 ? 'Asc' : 'Desc'),
+ text: `
+SELECT z."value",
+ z."score"
+ FROM "legacy_object_live" o
+ INNER JOIN "legacy_zset" z
+ ON o."_key" = z."_key"
+ AND o."type" = z."type"
+ WHERE o."_key" = ANY($1::TEXT[])
+ ORDER BY z."score" ` + (sort > 0 ? 'ASC' : 'DESC') + `
+ LIMIT $3::INTEGER
+OFFSET $2::INTEGER`,
+ values: [key, start, limit],
+ }, function (err, res) {
+ if (err) {
+ return callback(err);
+ }
+
+ if (reverse) {
+ res.rows.reverse();
+ }
+
+ if (withScores) {
+ res.rows = res.rows.map(function (r) {
+ return {
+ value: r.value,
+ score: parseFloat(r.score),
+ };
+ });
+ } else {
+ res.rows = res.rows.map(function (r) {
+ return r.value;
+ });
+ }
+
+ callback(null, res.rows);
+ });
+ }
+
+ module.getSortedSetRangeByScore = function (key, start, count, min, max, callback) {
+ getSortedSetRangeByScore(key, start, count, min, max, 1, false, callback);
+ };
+
+ module.getSortedSetRevRangeByScore = function (key, start, count, max, min, callback) {
+ getSortedSetRangeByScore(key, start, count, min, max, -1, false, callback);
+ };
+
+ module.getSortedSetRangeByScoreWithScores = function (key, start, count, min, max, callback) {
+ getSortedSetRangeByScore(key, start, count, min, max, 1, true, callback);
+ };
+
+ module.getSortedSetRevRangeByScoreWithScores = function (key, start, count, max, min, callback) {
+ getSortedSetRangeByScore(key, start, count, min, max, -1, true, callback);
+ };
+
+ function getSortedSetRangeByScore(key, start, count, min, max, sort, withScores, callback) {
+ if (!key) {
+ return callback();
+ }
+
+ if (!Array.isArray(key)) {
+ key = [key];
+ }
+
+ if (parseInt(count, 10) === -1) {
+ count = null;
+ }
+
+ if (min === '-inf') {
+ min = null;
+ }
+ if (max === '+inf') {
+ max = null;
+ }
+
+ query({
+ name: 'getSortedSetRangeByScoreWithScores' + (sort > 0 ? 'Asc' : 'Desc'),
+ text: `
+SELECT z."value",
+ z."score"
+ FROM "legacy_object_live" o
+ INNER JOIN "legacy_zset" z
+ ON o."_key" = z."_key"
+ AND o."type" = z."type"
+ WHERE o."_key" = ANY($1::TEXT[])
+ AND (z."score" >= $4::NUMERIC OR $4::NUMERIC IS NULL)
+ AND (z."score" <= $5::NUMERIC OR $5::NUMERIC IS NULL)
+ ORDER BY z."score" ` + (sort > 0 ? 'ASC' : 'DESC') + `
+ LIMIT $3::INTEGER
+OFFSET $2::INTEGER`,
+ values: [key, start, count, min, max],
+ }, function (err, res) {
+ if (err) {
+ return callback(err);
+ }
+
+ if (withScores) {
+ res.rows = res.rows.map(function (r) {
+ return {
+ value: r.value,
+ score: parseFloat(r.score),
+ };
+ });
+ } else {
+ res.rows = res.rows.map(function (r) {
+ return r.value;
+ });
+ }
+
+ return callback(null, res.rows);
+ });
+ }
+
+ module.sortedSetCount = function (key, min, max, callback) {
+ if (!key) {
+ return callback();
+ }
+
+ if (min === '-inf') {
+ min = null;
+ }
+ if (max === '+inf') {
+ max = null;
+ }
+
+ query({
+ name: 'sortedSetCount',
+ text: `
+SELECT COUNT(*) c
+ FROM "legacy_object_live" o
+ INNER JOIN "legacy_zset" z
+ ON o."_key" = z."_key"
+ AND o."type" = z."type"
+ WHERE o."_key" = $1::TEXT
+ AND (z."score" >= $2::NUMERIC OR $2::NUMERIC IS NULL)
+ AND (z."score" <= $3::NUMERIC OR $3::NUMERIC IS NULL)`,
+ values: [key, min, max],
+ }, function (err, res) {
+ if (err) {
+ return callback(err);
+ }
+
+ callback(null, parseInt(res.rows[0].c, 10));
+ });
+ };
+
+ module.sortedSetCard = function (key, callback) {
+ if (!key) {
+ return callback(null, 0);
+ }
+
+ query({
+ name: 'sortedSetCard',
+ text: `
+SELECT COUNT(*) c
+ FROM "legacy_object_live" o
+ INNER JOIN "legacy_zset" z
+ ON o."_key" = z."_key"
+ AND o."type" = z."type"
+ WHERE o."_key" = $1::TEXT`,
+ values: [key],
+ }, function (err, res) {
+ if (err) {
+ return callback(err);
+ }
+
+ callback(null, parseInt(res.rows[0].c, 10));
+ });
+ };
+
+ module.sortedSetsCard = function (keys, callback) {
+ if (!Array.isArray(keys) || !keys.length) {
+ return callback();
+ }
+
+ query({
+ name: 'sortedSetsCard',
+ text: `
+SELECT o."_key" k,
+ COUNT(*) c
+ FROM "legacy_object_live" o
+ INNER JOIN "legacy_zset" z
+ ON o."_key" = z."_key"
+ AND o."type" = z."type"
+ WHERE o."_key" = ANY($1::TEXT[])
+ GROUP BY o."_key"`,
+ values: [keys],
+ }, function (err, res) {
+ if (err) {
+ return callback(err);
+ }
+
+ callback(null, keys.map(function (k) {
+ return parseInt((res.rows.find(function (r) {
+ return r.k === k;
+ }) || { c: 0 }).c, 10);
+ }));
+ });
+ };
+
+ module.sortedSetRank = function (key, value, callback) {
+ getSortedSetRank('ASC', [key], [value], function (err, result) {
+ callback(err, result ? result[0] : null);
+ });
+ };
+
+ module.sortedSetRevRank = function (key, value, callback) {
+ getSortedSetRank('DESC', [key], [value], function (err, result) {
+ callback(err, result ? result[0] : null);
+ });
+ };
+
+ function getSortedSetRank(sort, keys, values, callback) {
+ values = values.map(helpers.valueToString);
+ query({
+ name: 'getSortedSetRank' + sort,
+ text: `
+SELECT (SELECT r
+ FROM (SELECT z."value" v,
+ RANK() OVER (PARTITION BY o."_key"
+ ORDER BY z."score" ` + sort + `,
+ z."value" ` + sort + `) - 1 r
+ FROM "legacy_object_live" o
+ INNER JOIN "legacy_zset" z
+ ON o."_key" = z."_key"
+ AND o."type" = z."type"
+ WHERE o."_key" = kvi.k) r
+ WHERE v = kvi.v) r
+ FROM UNNEST($1::TEXT[], $2::TEXT[]) WITH ORDINALITY kvi(k, v, i)
+ ORDER BY kvi.i ASC`,
+ values: [keys, values],
+ }, function (err, res) {
+ if (err) {
+ return callback(err);
+ }
+
+ callback(null, res.rows.map(function (r) { return r.r === null ? null : parseFloat(r.r); }));
+ });
+ }
+
+ module.sortedSetsRanks = function (keys, values, callback) {
+ if (!Array.isArray(keys) || !keys.length) {
+ return callback(null, []);
+ }
+
+ getSortedSetRank('ASC', keys, values, callback);
+ };
+
+ module.sortedSetRanks = function (key, values, callback) {
+ if (!Array.isArray(values) || !values.length) {
+ return callback(null, []);
+ }
+
+ getSortedSetRank('ASC', new Array(values.length).fill(key), values, callback);
+ };
+
+ module.sortedSetScore = function (key, value, callback) {
+ if (!key) {
+ return callback(null, null);
+ }
+
+ value = helpers.valueToString(value);
+
+ query({
+ name: 'sortedSetScore',
+ text: `
+SELECT z."score" s
+ FROM "legacy_object_live" o
+ INNER JOIN "legacy_zset" z
+ ON o."_key" = z."_key"
+ AND o."type" = z."type"
+ WHERE o."_key" = $1::TEXT
+ AND z."value" = $2::TEXT`,
+ values: [key, value],
+ }, function (err, res) {
+ if (err) {
+ return callback(err);
+ }
+
+ if (res.rows.length) {
+ return callback(null, parseFloat(res.rows[0].s));
+ }
+
+ callback(null, null);
+ });
+ };
+
+ module.sortedSetsScore = function (keys, value, callback) {
+ if (!Array.isArray(keys) || !keys.length) {
+ return callback();
+ }
+
+ value = helpers.valueToString(value);
+
+ query({
+ name: 'sortedSetsScore',
+ text: `
+SELECT o."_key" k,
+ z."score" s
+ FROM "legacy_object_live" o
+ INNER JOIN "legacy_zset" z
+ ON o."_key" = z."_key"
+ AND o."type" = z."type"
+ WHERE o."_key" = ANY($1::TEXT[])
+ AND z."value" = $2::TEXT`,
+ values: [keys, value],
+ }, function (err, res) {
+ if (err) {
+ return callback(err);
+ }
+
+ callback(null, keys.map(function (k) {
+ var s = res.rows.find(function (r) {
+ return r.k === k;
+ });
+
+ return s ? parseFloat(s.s) : null;
+ }));
+ });
+ };
+
+ module.sortedSetScores = function (key, values, callback) {
+ if (!key) {
+ return callback(null, null);
+ }
+
+ values = values.map(helpers.valueToString);
+
+ query({
+ name: 'sortedSetScores',
+ text: `
+SELECT z."value" v,
+ z."score" s
+ FROM "legacy_object_live" o
+ INNER JOIN "legacy_zset" z
+ ON o."_key" = z."_key"
+ AND o."type" = z."type"
+ WHERE o."_key" = $1::TEXT
+ AND z."value" = ANY($2::TEXT[])`,
+ values: [key, values],
+ }, function (err, res) {
+ if (err) {
+ return callback(err);
+ }
+
+ callback(null, values.map(function (v) {
+ var s = res.rows.find(function (r) {
+ return r.v === v;
+ });
+
+ return s ? parseFloat(s.s) : null;
+ }));
+ });
+ };
+
+ module.isSortedSetMember = function (key, value, callback) {
+ if (!key) {
+ return callback();
+ }
+
+ value = helpers.valueToString(value);
+
+ query({
+ name: 'isSortedSetMember',
+ text: `
+SELECT 1
+ FROM "legacy_object_live" o
+ INNER JOIN "legacy_zset" z
+ ON o."_key" = z."_key"
+ AND o."type" = z."type"
+ WHERE o."_key" = $1::TEXT
+ AND z."value" = $2::TEXT`,
+ values: [key, value],
+ }, function (err, res) {
+ if (err) {
+ return callback(err);
+ }
+
+ callback(null, !!res.rows.length);
+ });
+ };
+
+ module.isSortedSetMembers = function (key, values, callback) {
+ if (!key) {
+ return callback();
+ }
+
+ values = values.map(helpers.valueToString);
+
+ query({
+ name: 'isSortedSetMembers',
+ text: `
+SELECT z."value" v
+ FROM "legacy_object_live" o
+ INNER JOIN "legacy_zset" z
+ ON o."_key" = z."_key"
+ AND o."type" = z."type"
+ WHERE o."_key" = $1::TEXT
+ AND z."value" = ANY($2::TEXT[])`,
+ values: [key, values],
+ }, function (err, res) {
+ if (err) {
+ return callback(err);
+ }
+
+ callback(null, values.map(function (v) {
+ return res.rows.some(function (r) {
+ return r.v === v;
+ });
+ }));
+ });
+ };
+
+ module.isMemberOfSortedSets = function (keys, value, callback) {
+ if (!Array.isArray(keys)) {
+ return callback();
+ }
+
+ value = helpers.valueToString(value);
+
+ query({
+ name: 'isMemberOfSortedSets',
+ text: `
+SELECT o."_key" k
+ FROM "legacy_object_live" o
+ INNER JOIN "legacy_zset" z
+ ON o."_key" = z."_key"
+ AND o."type" = z."type"
+ WHERE o."_key" = ANY($1::TEXT[])
+ AND z."value" = $2::TEXT`,
+ values: [keys, value],
+ }, function (err, res) {
+ if (err) {
+ return callback(err);
+ }
+
+ callback(null, keys.map(function (k) {
+ return res.rows.some(function (r) {
+ return r.k === k;
+ });
+ }));
+ });
+ };
+
+ module.getSortedSetsMembers = function (keys, callback) {
+ if (!Array.isArray(keys) || !keys.length) {
+ return callback(null, []);
+ }
+
+ query({
+ name: 'getSortedSetsMembers',
+ text: `
+SELECT o."_key" k,
+ array_agg(z."value" ORDER BY z."score" ASC) m
+ FROM "legacy_object_live" o
+ INNER JOIN "legacy_zset" z
+ ON o."_key" = z."_key"
+ AND o."type" = z."type"
+ WHERE o."_key" = ANY($1::TEXT[])
+ GROUP BY o."_key"`,
+ values: [keys],
+ }, function (err, res) {
+ if (err) {
+ return callback(err);
+ }
+
+ callback(null, keys.map(function (k) {
+ return (res.rows.find(function (r) {
+ return r.k === k;
+ }) || { m: [] }).m;
+ }));
+ });
+ };
+
+ module.sortedSetIncrBy = function (key, increment, value, callback) {
+ callback = callback || helpers.noop;
+
+ if (!key) {
+ return callback();
+ }
+
+ value = helpers.valueToString(value);
+ increment = parseFloat(increment);
+
+ module.transaction(function (tx, done) {
+ async.waterfall([
+ async.apply(helpers.ensureLegacyObjectType, tx.client, key, 'zset'),
+ async.apply(tx.client.query.bind(tx.client), {
+ name: 'sortedSetIncrBy',
+ text: `
+INSERT INTO "legacy_zset" ("_key", "value", "score")
+VALUES ($1::TEXT, $2::TEXT, $3::NUMERIC)
+ ON CONFLICT ("_key", "value")
+ DO UPDATE SET "score" = "legacy_zset"."score" + $3::NUMERIC
+RETURNING "score" s`,
+ values: [key, value, increment],
+ }),
+ function (res, next) {
+ next(null, parseFloat(res.rows[0].s));
+ },
+ ], done);
+ }, callback);
+ };
+
+ module.getSortedSetRangeByLex = function (key, min, max, start, count, callback) {
+ sortedSetLex(key, min, max, 1, start, count, callback);
+ };
+
+ module.getSortedSetRevRangeByLex = function (key, max, min, start, count, callback) {
+ sortedSetLex(key, min, max, -1, start, count, callback);
+ };
+
+ module.sortedSetLexCount = function (key, min, max, callback) {
+ var q = buildLexQuery(key, min, max);
+
+ query({
+ name: 'sortedSetLexCount' + q.suffix,
+ text: `
+SELECT COUNT(*) c
+ FROM "legacy_object_live" o
+ INNER JOIN "legacy_zset" z
+ ON o."_key" = z."_key"
+ AND o."type" = z."type"
+ WHERE ` + q.where,
+ values: q.values,
+ }, function (err, res) {
+ if (err) {
+ return callback(err);
+ }
+
+ callback(null, parseInt(res.rows[0].c, 10));
+ });
+ };
+
+ function sortedSetLex(key, min, max, sort, start, count, callback) {
+ if (!callback) {
+ callback = start;
+ start = 0;
+ count = 0;
+ }
+
+ var q = buildLexQuery(key, min, max);
+ q.values.push(start);
+ q.values.push(count <= 0 ? null : count);
+ query({
+ name: 'sortedSetLex' + (sort > 0 ? 'Asc' : 'Desc') + q.suffix,
+ text: `
+SELECT z."value" v
+ FROM "legacy_object_live" o
+ INNER JOIN "legacy_zset" z
+ ON o."_key" = z."_key"
+ AND o."type" = z."type"
+ WHERE ` + q.where + `
+ ORDER BY z."value" ` + (sort > 0 ? 'ASC' : 'DESC') + `
+ LIMIT $` + q.values.length + `::INTEGER
+OFFSET $` + (q.values.length - 1) + `::INTEGER`,
+ values: q.values,
+ }, function (err, res) {
+ if (err) {
+ return callback(err);
+ }
+
+ callback(null, res.rows.map(function (r) {
+ return r.v;
+ }));
+ });
+ }
+
+ module.sortedSetRemoveRangeByLex = function (key, min, max, callback) {
+ callback = callback || helpers.noop;
+
+ var q = buildLexQuery(key, min, max);
+ query({
+ name: 'sortedSetRemoveRangeByLex' + q.suffix,
+ text: `
+DELETE FROM "legacy_zset" z
+ USING "legacy_object_live" o
+ WHERE o."_key" = z."_key"
+ AND o."type" = z."type"
+ AND ` + q.where,
+ values: q.values,
+ }, function (err) {
+ callback(err);
+ });
+ };
+
+ function buildLexQuery(key, min, max) {
+ var q = {
+ suffix: '',
+ where: `o."_key" = $1::TEXT`,
+ values: [key],
+ };
+
+ if (min !== '-') {
+ if (min.match(/^\(/)) {
+ q.values.push(min.substr(1));
+ q.suffix += 'GT';
+ q.where += ` AND z."value" > $` + q.values.length + `::TEXT`;
+ } else if (min.match(/^\[/)) {
+ q.values.push(min.substr(1));
+ q.suffix += 'GE';
+ q.where += ` AND z."value" >= $` + q.values.length + `::TEXT`;
+ } else {
+ q.values.push(min);
+ q.suffix += 'GE';
+ q.where += ` AND z."value" >= $` + q.values.length + `::TEXT`;
+ }
+ }
+
+ if (max !== '+') {
+ if (max.match(/^\(/)) {
+ q.values.push(max.substr(1));
+ q.suffix += 'LT';
+ q.where += ` AND z."value" < $` + q.values.length + `::TEXT`;
+ } else if (max.match(/^\[/)) {
+ q.values.push(max.substr(1));
+ q.suffix += 'LE';
+ q.where += ` AND z."value" <= $` + q.values.length + `::TEXT`;
+ } else {
+ q.values.push(max);
+ q.suffix += 'LE';
+ q.where += ` AND z."value" <= $` + q.values.length + `::TEXT`;
+ }
+ }
+
+ return q;
+ }
+
+ module.processSortedSet = function (setKey, process, options, callback) {
+ var Cursor = require('pg-cursor');
+
+ db.connect(function (err, client, done) {
+ if (err) {
+ return callback(err);
+ }
+
+ var batchSize = (options || {}).batch || 100;
+ var query = client.query(new Cursor(`
+SELECT z."value", z."score"
+ FROM "legacy_object_live" o
+ INNER JOIN "legacy_zset" z
+ ON o."_key" = z."_key"
+ AND o."type" = z."type"
+ WHERE o."_key" = $1::TEXT
+ ORDER BY z."score" ASC, z."value" ASC`, [setKey]));
+
+ async.doUntil(function (next) {
+ query.read(batchSize, function (err, rows) {
+ if (err) {
+ return next(err);
+ }
+
+ if (!rows.length) {
+ return next(null, true);
+ }
+
+ rows = rows.map(function (row) {
+ return options.withScores ? row : row.value;
+ });
+
+ process(rows, function (err) {
+ if (err) {
+ return query.close(function () {
+ next(err);
+ });
+ }
+
+ if (options.interval) {
+ setTimeout(next, options.interval);
+ } else {
+ next();
+ }
+ });
+ });
+ }, function (stop) {
+ return stop;
+ }, function (err) {
+ done();
+ callback(err);
+ });
+ });
+ };
+};
diff --git a/src/database/postgres/sorted/add.js b/src/database/postgres/sorted/add.js
new file mode 100644
index 0000000000..a187091746
--- /dev/null
+++ b/src/database/postgres/sorted/add.js
@@ -0,0 +1,108 @@
+'use strict';
+
+var async = require('async');
+
+module.exports = function (db, module) {
+ var helpers = module.helpers.postgres;
+
+ module.sortedSetAdd = function (key, score, value, callback) {
+ callback = callback || helpers.noop;
+
+ if (!key) {
+ return callback();
+ }
+
+ if (Array.isArray(score) && Array.isArray(value)) {
+ return sortedSetAddBulk(key, score, value, callback);
+ }
+
+ value = helpers.valueToString(value);
+ score = parseFloat(score);
+
+ module.transaction(function (tx, done) {
+ var query = tx.client.query.bind(tx.client);
+
+ async.series([
+ async.apply(helpers.ensureLegacyObjectType, tx.client, key, 'zset'),
+ async.apply(query, {
+ name: 'sortedSetAdd',
+ text: `
+INSERT INTO "legacy_zset" ("_key", "value", "score")
+VALUES ($1::TEXT, $2::TEXT, $3::NUMERIC)
+ ON CONFLICT ("_key", "value")
+ DO UPDATE SET "score" = $3::NUMERIC`,
+ values: [key, value, score],
+ }),
+ ], function (err) {
+ done(err);
+ });
+ }, callback);
+ };
+
+ function sortedSetAddBulk(key, scores, values, callback) {
+ if (!scores.length || !values.length) {
+ return callback();
+ }
+ if (scores.length !== values.length) {
+ return callback(new Error('[[error:invalid-data]]'));
+ }
+
+ values = values.map(helpers.valueToString);
+ scores = scores.map(function (score) {
+ return parseFloat(score);
+ });
+
+ helpers.removeDuplicateValues(values, scores);
+
+ module.transaction(function (tx, done) {
+ var query = tx.client.query.bind(tx.client);
+
+ async.series([
+ async.apply(helpers.ensureLegacyObjectType, tx.client, key, 'zset'),
+ async.apply(query, {
+ name: 'sortedSetAddBulk',
+ text: `
+INSERT INTO "legacy_zset" ("_key", "value", "score")
+SELECT $1::TEXT, v, s
+ FROM UNNEST($2::TEXT[], $3::NUMERIC[]) vs(v, s)
+ ON CONFLICT ("_key", "value")
+ DO UPDATE SET "score" = EXCLUDED."score"`,
+ values: [key, values, scores],
+ }),
+ ], function (err) {
+ done(err);
+ });
+ }, callback);
+ }
+
+ module.sortedSetsAdd = function (keys, score, value, callback) {
+ callback = callback || helpers.noop;
+
+ if (!Array.isArray(keys) || !keys.length) {
+ return callback();
+ }
+
+ value = helpers.valueToString(value);
+ score = parseFloat(score);
+
+ module.transaction(function (tx, done) {
+ var query = tx.client.query.bind(tx.client);
+
+ async.series([
+ async.apply(helpers.ensureLegacyObjectsType, tx.client, keys, 'zset'),
+ async.apply(query, {
+ name: 'sortedSetsAdd',
+ text: `
+INSERT INTO "legacy_zset" ("_key", "value", "score")
+SELECT k, $2::TEXT, $3::NUMERIC
+ FROM UNNEST($1::TEXT[]) k
+ ON CONFLICT ("_key", "value")
+ DO UPDATE SET "score" = $3::NUMERIC`,
+ values: [keys, value, score],
+ }),
+ ], function (err) {
+ done(err);
+ });
+ }, callback);
+ };
+};
diff --git a/src/database/postgres/sorted/intersect.js b/src/database/postgres/sorted/intersect.js
new file mode 100644
index 0000000000..47fe5b9b16
--- /dev/null
+++ b/src/database/postgres/sorted/intersect.js
@@ -0,0 +1,105 @@
+'use strict';
+
+module.exports = function (db, module) {
+ module.sortedSetIntersectCard = function (keys, callback) {
+ if (!Array.isArray(keys) || !keys.length) {
+ return callback(null, 0);
+ }
+
+ db.query({
+ name: 'sortedSetIntersectCard',
+ text: `
+WITH A AS (SELECT z."value" v,
+ COUNT(*) c
+ FROM "legacy_object_live" o
+ INNER JOIN "legacy_zset" z
+ ON o."_key" = z."_key"
+ AND o."type" = z."type"
+ WHERE o."_key" = ANY($1::TEXT[])
+ GROUP BY z."value")
+SELECT COUNT(*) c
+ FROM A
+ WHERE A.c = array_length($1::TEXT[], 1)`,
+ values: [keys],
+ }, function (err, res) {
+ if (err) {
+ return callback(err);
+ }
+
+ callback(null, parseInt(res.rows[0].c, 10));
+ });
+ };
+
+
+ module.getSortedSetIntersect = function (params, callback) {
+ params.sort = 1;
+ getSortedSetIntersect(params, callback);
+ };
+
+ module.getSortedSetRevIntersect = function (params, callback) {
+ params.sort = -1;
+ getSortedSetIntersect(params, callback);
+ };
+
+ function getSortedSetIntersect(params, callback) {
+ var sets = params.sets;
+ var start = params.hasOwnProperty('start') ? params.start : 0;
+ var stop = params.hasOwnProperty('stop') ? params.stop : -1;
+ var weights = params.weights || [];
+ var aggregate = params.aggregate || 'SUM';
+
+ if (sets.length < weights.length) {
+ weights = weights.slice(0, sets.length);
+ }
+ while (sets.length > weights.length) {
+ weights.push(1);
+ }
+
+ var limit = stop - start + 1;
+ if (limit <= 0) {
+ limit = null;
+ }
+
+ db.query({
+ name: 'getSortedSetIntersect' + aggregate + (params.sort > 0 ? 'Asc' : 'Desc') + 'WithScores',
+ text: `
+WITH A AS (SELECT z."value",
+ ` + aggregate + `(z."score" * k."weight") "score",
+ COUNT(*) c
+ FROM UNNEST($1::TEXT[], $2::NUMERIC[]) k("_key", "weight")
+ INNER JOIN "legacy_object_live" o
+ ON o."_key" = k."_key"
+ INNER JOIN "legacy_zset" z
+ ON o."_key" = z."_key"
+ AND o."type" = z."type"
+ GROUP BY z."value")
+SELECT A."value",
+ A."score"
+ FROM A
+ WHERE c = array_length($1::TEXT[], 1)
+ ORDER BY A."score" ` + (params.sort > 0 ? 'ASC' : 'DESC') + `
+ LIMIT $4::INTEGER
+OFFSET $3::INTEGER`,
+ values: [sets, weights, start, limit],
+ }, function (err, res) {
+ if (err) {
+ return callback(err);
+ }
+
+ if (params.withScores) {
+ res.rows = res.rows.map(function (r) {
+ return {
+ value: r.value,
+ score: parseFloat(r.score),
+ };
+ });
+ } else {
+ res.rows = res.rows.map(function (r) {
+ return r.value;
+ });
+ }
+
+ callback(null, res.rows);
+ });
+ }
+};
diff --git a/src/database/postgres/sorted/remove.js b/src/database/postgres/sorted/remove.js
new file mode 100644
index 0000000000..6118b22981
--- /dev/null
+++ b/src/database/postgres/sorted/remove.js
@@ -0,0 +1,83 @@
+'use strict';
+
+module.exports = function (db, module) {
+ var helpers = module.helpers.postgres;
+
+ module.sortedSetRemove = function (key, value, callback) {
+ function done(err) {
+ if (callback) {
+ callback(err);
+ }
+ }
+
+ if (!key) {
+ return done();
+ }
+
+ if (!Array.isArray(key)) {
+ key = [key];
+ }
+
+ if (!Array.isArray(value)) {
+ value = [value];
+ }
+ value = value.map(helpers.valueToString);
+
+ db.query({
+ name: 'sortedSetRemove',
+ text: `
+DELETE FROM "legacy_zset"
+ WHERE "_key" = ANY($1::TEXT[])
+ AND "value" = ANY($2::TEXT[])`,
+ values: [key, value],
+ }, done);
+ };
+
+ module.sortedSetsRemove = function (keys, value, callback) {
+ callback = callback || helpers.noop;
+
+ if (!Array.isArray(keys) || !keys.length) {
+ return callback();
+ }
+
+ value = helpers.valueToString(value);
+
+ db.query({
+ name: 'sortedSetsRemove',
+ text: `
+DELETE FROM "legacy_zset"
+ WHERE "_key" = ANY($1::TEXT[])
+ AND "value" = $2::TEXT`,
+ values: [keys, value],
+ }, function (err) {
+ callback(err);
+ });
+ };
+
+ module.sortedSetsRemoveRangeByScore = function (keys, min, max, callback) {
+ callback = callback || helpers.noop;
+
+ if (!Array.isArray(keys) || !keys.length) {
+ return callback();
+ }
+
+ if (min === '-inf') {
+ min = null;
+ }
+ if (max === '+inf') {
+ max = null;
+ }
+
+ db.query({
+ name: 'sortedSetsRemoveRangeByScore',
+ text: `
+DELETE FROM "legacy_zset"
+ WHERE "_key" = ANY($1::TEXT[])
+ AND ("score" >= $2::NUMERIC OR $2::NUMERIC IS NULL)
+ AND ("score" <= $3::NUMERIC OR $3::NUMERIC IS NULL)`,
+ values: [keys, min, max],
+ }, function (err) {
+ callback(err);
+ });
+ };
+};
diff --git a/src/database/postgres/sorted/union.js b/src/database/postgres/sorted/union.js
new file mode 100644
index 0000000000..2f991dc761
--- /dev/null
+++ b/src/database/postgres/sorted/union.js
@@ -0,0 +1,97 @@
+'use strict';
+
+module.exports = function (db, module) {
+ module.sortedSetUnionCard = function (keys, callback) {
+ if (!Array.isArray(keys) || !keys.length) {
+ return callback(null, 0);
+ }
+
+ db.query({
+ name: 'sortedSetUnionCard',
+ text: `
+SELECT COUNT(DISTINCT z."value") c
+ FROM "legacy_object_live" o
+ INNER JOIN "legacy_zset" z
+ ON o."_key" = z."_key"
+ AND o."type" = z."type"
+ WHERE o."_key" = ANY($1::TEXT[])`,
+ values: [keys],
+ }, function (err, res) {
+ if (err) {
+ return callback(err);
+ }
+
+ callback(null, parseInt(res.rows[0].c, 10));
+ });
+ };
+
+ module.getSortedSetUnion = function (params, callback) {
+ params.sort = 1;
+ getSortedSetUnion(params, callback);
+ };
+
+ module.getSortedSetRevUnion = function (params, callback) {
+ params.sort = -1;
+ getSortedSetUnion(params, callback);
+ };
+
+ function getSortedSetUnion(params, callback) {
+ var sets = params.sets;
+ var start = params.hasOwnProperty('start') ? params.start : 0;
+ var stop = params.hasOwnProperty('stop') ? params.stop : -1;
+ var weights = params.weights || [];
+ var aggregate = params.aggregate || 'SUM';
+
+ if (sets.length < weights.length) {
+ weights = weights.slice(0, sets.length);
+ }
+ while (sets.length > weights.length) {
+ weights.push(1);
+ }
+
+ var limit = stop - start + 1;
+ if (limit <= 0) {
+ limit = null;
+ }
+
+ db.query({
+ name: 'getSortedSetUnion' + aggregate + (params.sort > 0 ? 'Asc' : 'Desc') + 'WithScores',
+ text: `
+WITH A AS (SELECT z."value",
+ ` + aggregate + `(z."score" * k."weight") "score"
+ FROM UNNEST($1::TEXT[], $2::NUMERIC[]) k("_key", "weight")
+ INNER JOIN "legacy_object_live" o
+ ON o."_key" = k."_key"
+ INNER JOIN "legacy_zset" z
+ ON o."_key" = z."_key"
+ AND o."type" = z."type"
+ GROUP BY z."value")
+SELECT A."value",
+ A."score"
+ FROM A
+ ORDER BY A."score" ` + (params.sort > 0 ? 'ASC' : 'DESC') + `
+ LIMIT $4::INTEGER
+OFFSET $3::INTEGER`,
+ values: [sets, weights, start, limit],
+ }, function (err, res) {
+ if (err) {
+ return callback(err);
+ }
+
+ if (params.withScores) {
+ res.rows = res.rows.map(function (r) {
+ return {
+ value: r.value,
+ score: parseFloat(r.score),
+ };
+ });
+ } else {
+ res.rows = res.rows.map(function (r) {
+ return r.value;
+ });
+ }
+
+ callback(null, res.rows);
+ });
+ }
+};
diff --git a/src/database/postgres/transaction.js b/src/database/postgres/transaction.js
new file mode 100644
index 0000000000..ed13d7a537
--- /dev/null
+++ b/src/database/postgres/transaction.js
@@ -0,0 +1,50 @@
+'use strict';
+
+module.exports = function (db, dbNamespace, module) {
+ module.transaction = function (perform, callback) {
+ if (dbNamespace.active && dbNamespace.get('db')) {
+ var client = dbNamespace.get('db');
+ return client.query(`SAVEPOINT nodebb_subtx`, function (err) {
+ if (err) {
+ return callback(err);
+ }
+
+ perform(module, function (err) {
+ var args = Array.prototype.slice.call(arguments, 1);
+
+ client.query(err ? `ROLLBACK TO SAVEPOINT nodebb_subtx` : `RELEASE SAVEPOINT nodebb_subtx`, function (err1) {
+ callback.apply(this, [err || err1].concat(args));
+ });
+ });
+ });
+ }
+
+ db.connect(function (err, client, done) {
+ if (err) {
+ return callback(err);
+ }
+
+ dbNamespace.run(function () {
+ dbNamespace.set('db', client);
+
+ client.query(`BEGIN`, function (err) {
+ if (err) {
+ done();
+ dbNamespace.set('db', null);
+ return callback(err);
+ }
+
+ perform(module, function (err) {
+ var args = Array.prototype.slice.call(arguments, 1);
+
+ client.query(err ? `ROLLBACK` : `COMMIT`, function (err1) {
+ done();
+ dbNamespace.set('db', null);
+ callback.apply(this, [err || err1].concat(args));
+ });
+ });
+ });
+ });
+ });
+ };
+};
diff --git a/src/database/redis.js b/src/database/redis.js
index 4bbd6ed0da..6d780d0853 100644
--- a/src/database/redis.js
+++ b/src/database/redis.js
@@ -50,6 +50,9 @@ redisModule.init = function (callback) {
require('./redis/sets')(redisClient, redisModule);
require('./redis/sorted')(redisClient, redisModule);
require('./redis/list')(redisClient, redisModule);
+ require('./redis/transaction')(redisClient, redisModule);
+
+ redisModule.async = require('../promisify')(redisModule, ['client', 'sessionStore']);
callback();
});
diff --git a/src/database/redis/hash.js b/src/database/redis/hash.js
index 9dd6276f88..3055143375 100644
--- a/src/database/redis/hash.js
+++ b/src/database/redis/hash.js
@@ -14,7 +14,7 @@ module.exports = function (redisClient, module) {
}
Object.keys(data).forEach(function (key) {
- if (data[key] === undefined) {
+ if (data[key] === undefined || data[key] === null) {
delete data[key];
}
});
@@ -26,6 +26,9 @@ module.exports = function (redisClient, module) {
module.setObjectField = function (key, field, value, callback) {
callback = callback || function () {};
+ if (!field) {
+ return callback();
+ }
redisClient.hset(key, field, value, function (err) {
callback(err);
});
diff --git a/src/database/redis/transaction.js b/src/database/redis/transaction.js
new file mode 100644
index 0000000000..75ea5fbaa2
--- /dev/null
+++ b/src/database/redis/transaction.js
@@ -0,0 +1,8 @@
+'use strict';
+
+module.exports = function (db, module) {
+ // TODO
+ module.transaction = function (perform, callback) {
+ perform(db, callback);
+ };
+};
diff --git a/src/emailer.js b/src/emailer.js
index 184eb5d95b..dbc56b9175 100644
--- a/src/emailer.js
+++ b/src/emailer.js
@@ -210,6 +210,12 @@ Emailer.sendToEmail = function (template, email, language, params, callback) {
var lang = language || meta.config.defaultLang || 'en-GB';
+ // Add some default email headers based on local configuration
+ params.headers = Object.assign({
+ 'List-Id': '<' + [template, params.uid, getHostname()].join('.') + '>',
+ 'List-Unsubscribe': '<' + [nconf.get('url'), 'uid', params.uid, 'settings'].join('/') + '>',
+ }, params.headers);
+
async.waterfall([
function (next) {
Plugins.fireHook('filter:email.params', {
@@ -249,6 +255,7 @@ Emailer.sendToEmail = function (template, email, language, params, callback) {
uid: params.uid,
pid: params.pid,
fromUid: params.fromUid,
+ headers: params.headers,
};
Plugins.fireHook('filter:email.modify', data, next);
},
@@ -289,22 +296,26 @@ Emailer.sendViaFallback = function (data, callback) {
function buildCustomTemplates(config) {
async.waterfall([
function (next) {
- Emailer.getTemplates(config, next);
+ async.parallel({
+ templates: function (cb) {
+ Emailer.getTemplates(config, cb);
+ },
+ paths: function (cb) {
+ file.walk(viewsDir, cb);
+ },
+ }, next);
},
- function (templates, next) {
- templates = templates.filter(function (template) {
+ function (result, next) {
+ var templates = result.templates.filter(function (template) {
return template.isCustom && template.text !== prevConfig['email:custom:' + path];
});
+ var paths = _.fromPairs(result.paths.map(function (p) {
+ var relative = path.relative(viewsDir, p).replace(/\\/g, '/');
+ return [relative, p];
+ }));
async.each(templates, function (template, next) {
async.waterfall([
function (next) {
- file.walk(viewsDir, next);
- },
- function (paths, next) {
- paths = _.fromPairs(paths.map(function (p) {
- var relative = path.relative(viewsDir, p).replace(/\\/g, '/');
- return [relative, p];
- }));
meta.templates.processImports(paths, template.path, template.text, next);
},
function (source, next) {
diff --git a/src/events.js b/src/events.js
index cb8798ed70..34dcf15ec4 100644
--- a/src/events.js
+++ b/src/events.js
@@ -4,6 +4,7 @@
var async = require('async');
var validator = require('validator');
var winston = require('winston');
+var _ = require('lodash');
var db = require('./database');
var batch = require('./batch');
@@ -12,6 +13,41 @@ var utils = require('./utils');
var events = module.exports;
+events.types = [
+ 'plugin-activate',
+ 'plugin-deactivate',
+ 'restart',
+ 'build',
+ 'config-change',
+ 'settings-change',
+ 'category-purge',
+ 'privilege-change',
+ 'post-delete',
+ 'post-restore',
+ 'post-purge',
+ 'topic-delete',
+ 'topic-restore',
+ 'topic-purge',
+ 'topic-rename',
+ 'password-reset',
+ 'user-ban',
+ 'user-unban',
+ 'user-delete',
+ 'password-change',
+ 'email-change',
+ 'username-change',
+ 'ip-blacklist-save',
+ 'ip-blacklist-addRule',
+ 'registration-approved',
+ 'registration-rejected',
+ 'accept-membership',
+ 'reject-membership',
+ 'theme-set',
+ 'export:uploads',
+ 'account-locked',
+ 'getUsersCSV',
+];
+
/**
* Useful options in data: type, uid, ip, targetUid
* Everything else gets stringified and shown as pretty JSON string
@@ -31,6 +67,9 @@ events.log = function (data, callback) {
function (next) {
db.sortedSetAdd('events:time', data.timestamp, eid, next);
},
+ function (next) {
+ db.sortedSetAdd('events:time:' + data.type, data.timestamp, eid, next);
+ },
function (next) {
db.setObject('event:' + eid, data, next);
},
@@ -41,10 +80,10 @@ events.log = function (data, callback) {
});
};
-events.getEvents = function (start, stop, callback) {
+events.getEvents = function (filter, start, stop, callback) {
async.waterfall([
function (next) {
- db.getSortedSetRevRange('events:time', start, stop, next);
+ db.getSortedSetRevRange('events:time' + (filter ? ':' + filter : ''), start, stop, next);
},
function (eids, next) {
var keys = eids.map(function (eid) {
@@ -123,15 +162,24 @@ function addUserData(eventsData, field, objectName, callback) {
events.deleteEvents = function (eids, callback) {
callback = callback || function () {};
- async.parallel([
+ var keys;
+ async.waterfall([
function (next) {
- var keys = eids.map(function (eid) {
+ keys = eids.map(function (eid) {
return 'event:' + eid;
});
- db.deleteAll(keys, next);
+ db.getObjectsFields(keys, ['type'], next);
},
- function (next) {
- db.sortedSetRemove('events:time', eids, next);
+ function (eventData, next) {
+ var sets = _.uniq(['events:time'].concat(eventData.map(e => 'events:time:' + e.type)));
+ async.parallel([
+ function (next) {
+ db.deleteAll(keys, next);
+ },
+ function (next) {
+ db.sortedSetRemove(sets, eids, next);
+ },
+ ], next);
},
], callback);
};
@@ -146,7 +194,7 @@ events.deleteAll = function (callback) {
events.output = function () {
console.log('\nDisplaying last ten administrative events...'.bold);
- events.getEvents(0, 9, function (err, events) {
+ events.getEvents('', 0, 9, function (err, events) {
if (err) {
winston.error('Error fetching events', err);
throw err;
diff --git a/src/file.js b/src/file.js
index 3f0aaabcf0..a1f5f0580c 100644
--- a/src/file.js
+++ b/src/file.js
@@ -4,7 +4,6 @@ var fs = require('fs');
var nconf = require('nconf');
var path = require('path');
var winston = require('winston');
-var jimp = require('jimp');
var mkdirp = require('mkdirp');
var mime = require('mime');
var graceful = require('graceful-fs');
@@ -107,12 +106,22 @@ file.isFileTypeAllowed = function (path, callback) {
});
}
- // Attempt to read the file, if it passes, file type is allowed
- jimp.read(path, function (err) {
+ require('sharp')(path, {
+ failOnError: true,
+ }).metadata(function (err) {
callback(err);
});
};
+// https://stackoverflow.com/a/31205878/583363
+file.appendToFileName = function (filename, string) {
+ var dotIndex = filename.lastIndexOf('.');
+ if (dotIndex === -1) {
+ return filename + string;
+ }
+ return filename.substring(0, dotIndex) + string + filename.substring(dotIndex);
+};
+
file.allowedExtensions = function () {
var meta = require('./meta');
var allowedExtensions = (meta.config.allowedFileExtensions || '').trim();
@@ -163,7 +172,7 @@ file.existsSync = function (path) {
file.delete = function (path, callback) {
callback = callback || function () {};
if (!path) {
- return callback();
+ return setImmediate(callback);
}
fs.unlink(path, function (err) {
if (err) {
diff --git a/src/flags.js b/src/flags.js
index 238bd7f204..c0c49c4a0a 100644
--- a/src/flags.js
+++ b/src/flags.js
@@ -320,6 +320,7 @@ Flags.getNotes = function (flagId, callback) {
next(null, notes.map(function (note, idx) {
note.user = users[idx];
+ note.content = validator.escape(note.content);
return note;
}));
});
@@ -496,7 +497,7 @@ Flags.update = function (flagId, uid, changeset, callback) {
var tasks = [];
var now = changeset.datetime || Date.now();
var notifyAssignee = function (assigneeId, next) {
- if (assigneeId === '') {
+ if (assigneeId === '' || parseInt(uid, 10) === parseInt(assigneeId, 10)) {
// Do nothing
return next();
}
@@ -688,7 +689,7 @@ Flags.notify = function (flagObj, uid, callback) {
bodyShort: '[[notifications:user_flagged_post_in, ' + flagObj.reporter.username + ', ' + titleEscaped + ']]',
bodyLong: flagObj.description,
pid: flagObj.targetId,
- path: '/post/' + flagObj.targetId,
+ path: '/flags/' + flagObj.flagId,
nid: 'flag:post:' + flagObj.targetId + ':uid:' + uid,
from: uid,
mergeId: 'notifications:user_flagged_post_in|' + flagObj.targetId,
@@ -725,7 +726,7 @@ Flags.notify = function (flagObj, uid, callback) {
type: 'new-user-flag',
bodyShort: '[[notifications:user_flagged_user, ' + flagObj.reporter.username + ', ' + flagObj.target.username + ']]',
bodyLong: flagObj.description,
- path: '/uid/' + flagObj.targetId,
+ path: '/flags/' + flagObj.flagId,
nid: 'flag:user:' + flagObj.targetId + ':uid:' + uid,
from: uid,
mergeId: 'notifications:user_flagged_user|' + flagObj.targetId,
diff --git a/src/groups.js b/src/groups.js
index 780eda46c4..9ac5cc8b85 100644
--- a/src/groups.js
+++ b/src/groups.js
@@ -301,3 +301,5 @@ Groups.existsBySlug = function (slug, callback) {
db.isObjectField('groupslug:groupname', slug, callback);
}
};
+
+Groups.async = require('./promisify')(Groups);
diff --git a/src/groups/cover.js b/src/groups/cover.js
index 289ee16302..ae2f86c90f 100644
--- a/src/groups/cover.js
+++ b/src/groups/cover.js
@@ -2,7 +2,6 @@
var async = require('async');
var path = require('path');
-var Jimp = require('jimp');
var mime = require('mime');
var db = require('../database');
@@ -27,7 +26,6 @@ module.exports = function (Groups) {
var tempPath = data.file ? data.file : '';
var url;
var type = data.file ? mime.getType(data.file) : 'image/png';
-
async.waterfall([
function (next) {
if (tempPath) {
@@ -49,7 +47,10 @@ module.exports = function (Groups) {
Groups.setGroupField(data.groupName, 'cover:url', url, next);
},
function (next) {
- resizeCover(tempPath, next);
+ image.resizeImage({
+ path: tempPath,
+ width: 358,
+ }, next);
},
function (next) {
uploadsController.uploadGroupCover(uid, {
@@ -74,22 +75,6 @@ module.exports = function (Groups) {
});
};
- function resizeCover(path, callback) {
- async.waterfall([
- function (next) {
- new Jimp(path, next);
- },
- function (image, next) {
- image.resize(358, Jimp.AUTO, next);
- },
- function (image, next) {
- image.write(path, next);
- },
- ], function (err) {
- callback(err);
- });
- }
-
Groups.removeCover = function (data, callback) {
db.deleteObjectFields('group:' + data.groupName, ['cover:url', 'cover:thumb:url', 'cover:position'], callback);
};
diff --git a/src/groups/membership.js b/src/groups/membership.js
index 09e86e12f6..1f6cc7af14 100644
--- a/src/groups/membership.js
+++ b/src/groups/membership.js
@@ -105,7 +105,7 @@ module.exports = function (Groups) {
return callback(err);
}
- user.setUserField(uid, 'groupTitle', groupName, callback);
+ user.setUserField(uid, 'groupTitle', JSON.stringify([groupName]), callback);
});
}
@@ -294,13 +294,17 @@ module.exports = function (Groups) {
}
async.waterfall([
function (next) {
- db.getObjectField('user:' + uid, 'groupTitle', next);
+ user.getUserData(uid, next);
},
- function (groupTitle, next) {
- if (groupNames.includes(groupTitle)) {
- db.deleteObjectField('user:' + uid, 'groupTitle', next);
+ function (userData, next) {
+ var newTitleArray = userData.groupTitleArray.filter(function (groupTitle) {
+ return !groupNames.includes(groupTitle);
+ });
+
+ if (newTitleArray.length) {
+ db.setObjectField('user:' + uid, 'groupTitle', JSON.stringify(newTitleArray), next);
} else {
- next();
+ db.deleteObjectField('user:' + uid, 'groupTitle', next);
}
},
], callback);
diff --git a/src/image.js b/src/image.js
index f99a73e3bc..9e65654507 100644
--- a/src/image.js
+++ b/src/image.js
@@ -3,21 +3,28 @@
var os = require('os');
var fs = require('fs');
var path = require('path');
-var Jimp = require('jimp');
-var async = require('async');
var crypto = require('crypto');
+var async = require('async');
var file = require('./file');
var plugins = require('./plugins');
var image = module.exports;
+function requireSharp() {
+ var sharp = require('sharp');
+ if (os.platform() === 'win32') {
+ // https://github.com/lovell/sharp/issues/1259
+ sharp.cache(false);
+ }
+ return sharp;
+}
+
image.resizeImage = function (data, callback) {
if (plugins.hasListeners('filter:image.resize')) {
plugins.fireHook('filter:image.resize', {
path: data.path,
target: data.target,
- extension: data.extension,
width: data.width,
height: data.height,
quality: data.quality,
@@ -25,64 +32,26 @@ image.resizeImage = function (data, callback) {
callback(err);
});
} else {
- new Jimp(data.path, function (err, image) {
- if (err) {
- return callback(err);
- }
+ async.waterfall([
+ function (next) {
+ fs.readFile(data.path, next);
+ },
+ function (buffer, next) {
+ var sharp = requireSharp();
+ var sharpImage = sharp(buffer, {
+ failOnError: true,
+ });
+ sharpImage.rotate(); // auto-orients based on exif data
+ sharpImage.resize(data.hasOwnProperty('width') ? data.width : null, data.hasOwnProperty('height') ? data.height : null);
- var w = image.bitmap.width;
- var h = image.bitmap.height;
- var origRatio = w / h;
- var desiredRatio = data.width && data.height ? data.width / data.height : origRatio;
- var x = 0;
- var y = 0;
- var crop;
-
- if (image._exif && image._exif.tags && image._exif.tags.Orientation) {
- image.exifRotate();
- }
-
- if (origRatio !== desiredRatio) {
- if (desiredRatio > origRatio) {
- desiredRatio = 1 / desiredRatio;
+ if (data.quality) {
+ sharpImage.jpeg({ quality: data.quality });
}
- if (origRatio >= 1) {
- y = 0; // height is the smaller dimension here
- x = Math.floor((w / 2) - (h * desiredRatio / 2));
- crop = async.apply(image.crop.bind(image), x, y, h * desiredRatio, h);
- } else {
- x = 0; // width is the smaller dimension here
- y = Math.floor((h / 2) - (w * desiredRatio / 2));
- crop = async.apply(image.crop.bind(image), x, y, w, w * desiredRatio);
- }
- } else {
- // Simple resize given either width, height, or both
- crop = async.apply(setImmediate);
- }
- async.waterfall([
- crop,
- function (_image, next) {
- if (typeof _image === 'function' && !next) {
- next = _image;
- _image = image;
- }
-
- if ((data.width && data.height) || (w > data.width) || (h > data.height)) {
- _image.resize(data.width || Jimp.AUTO, data.height || Jimp.AUTO, next);
- } else {
- next(null, image);
- }
- },
- function (image, next) {
- if (data.quality) {
- image.quality(data.quality);
- }
- image.write(data.target || data.path, next);
- },
- ], function (err) {
- callback(err);
- });
+ sharpImage.toFile(data.target || data.path, next);
+ },
+ ], function (err) {
+ callback(err);
});
}
};
@@ -91,21 +60,14 @@ image.normalise = function (path, extension, callback) {
if (plugins.hasListeners('filter:image.normalise')) {
plugins.fireHook('filter:image.normalise', {
path: path,
- extension: extension,
}, function (err) {
callback(err, path + '.png');
});
} else {
- async.waterfall([
- function (next) {
- new Jimp(path, next);
- },
- function (image, next) {
- image.write(path + '.png', function (err) {
- next(err, path + '.png');
- });
- },
- ], callback);
+ var sharp = requireSharp();
+ sharp(path, { failOnError: true }).png().toFile(path + '.png', function (err) {
+ callback(err, path + '.png');
+ });
}
};
@@ -114,15 +76,33 @@ image.size = function (path, callback) {
plugins.fireHook('filter:image.size', {
path: path,
}, function (err, image) {
- callback(err, image);
+ callback(err, image ? { width: image.width, height: image.height } : undefined);
});
} else {
- new Jimp(path, function (err, data) {
- callback(err, data ? data.bitmap : null);
+ var sharp = requireSharp();
+ sharp(path, { failOnError: true }).metadata(function (err, metadata) {
+ callback(err, metadata ? { width: metadata.width, height: metadata.height } : undefined);
});
}
};
+image.checkDimensions = function (path, callback) {
+ const meta = require('./meta');
+ image.size(path, function (err, result) {
+ if (err) {
+ return callback(err);
+ }
+
+ const maxWidth = parseInt(meta.config.rejectImageWidth, 10) || 5000;
+ const maxHeight = parseInt(meta.config.rejectImageHeight, 10) || 5000;
+ if (result.width > maxWidth || result.height > maxHeight) {
+ return callback(new Error('[[error:invalid-image-dimensions]]'));
+ }
+
+ callback();
+ });
+};
+
image.convertImageToBase64 = function (path, callback) {
fs.readFile(path, 'base64', callback);
};
@@ -151,3 +131,7 @@ image.writeImageDataToTempFile = function (imageData, callback) {
callback(err, filepath);
});
};
+
+image.sizeFromBase64 = function (imageData) {
+ return Buffer.from(imageData.slice(imageData.indexOf('base64') + 7), 'base64').length;
+};
diff --git a/src/install.js b/src/install.js
index 25cb213488..5ef191cf44 100644
--- a/src/install.js
+++ b/src/install.js
@@ -126,7 +126,8 @@ function setupConfig(next) {
var config = {};
var redisQuestions = require('./database/redis').questions;
var mongoQuestions = require('./database/mongo').questions;
- var allQuestions = questions.main.concat(questions.optional).concat(redisQuestions).concat(mongoQuestions);
+ var postgresQuestions = require('./database/postgres').questions;
+ var allQuestions = questions.main.concat(questions.optional).concat(redisQuestions).concat(mongoQuestions).concat(postgresQuestions);
allQuestions.forEach(function (question) {
config[question.name] = install.values[question.name] || question.default || undefined;
@@ -380,7 +381,10 @@ function createGlobalModeratorsGroup(next) {
function giveGlobalPrivileges(next) {
var privileges = require('./privileges');
- var defaultPrivileges = ['chat', 'upload:post:image', 'signature', 'search:content', 'search:users', 'search:tags'];
+ var defaultPrivileges = [
+ 'chat', 'upload:post:image', 'signature', 'search:content',
+ 'search:users', 'search:tags', 'local:login',
+ ];
privileges.global.give(defaultPrivileges, 'registered-users', next);
}
diff --git a/src/messaging.js b/src/messaging.js
index ba9e794b30..543d6d03a7 100644
--- a/src/messaging.js
+++ b/src/messaging.js
@@ -383,3 +383,5 @@ Messaging.hasPrivateChat = function (uid, withUid, callback) {
},
], callback);
};
+
+Messaging.async = require('./promisify')(Messaging);
diff --git a/src/messaging/create.js b/src/messaging/create.js
index bcee2f4f5d..c173528c65 100644
--- a/src/messaging/create.js
+++ b/src/messaging/create.js
@@ -30,13 +30,20 @@ module.exports = function (Messaging) {
if (!content) {
return callback(new Error('[[error:invalid-chat-message]]'));
}
- content = String(content);
- var maximumChatMessageLength = (meta.config.maximumChatMessageLength || 1000);
- if (content.length > maximumChatMessageLength) {
- return callback(new Error('[[error:chat-message-too-long, ' + maximumChatMessageLength + ']]'));
- }
- callback();
+ plugins.fireHook('filter:messaging.checkContent', { content: content }, function (err, data) {
+ if (err) {
+ return callback(err);
+ }
+
+ content = String(data.content);
+
+ var maximumChatMessageLength = (meta.config.maximumChatMessageLength || 1000);
+ if (content.length > maximumChatMessageLength) {
+ return callback(new Error('[[error:chat-message-too-long, ' + maximumChatMessageLength + ']]'));
+ }
+ callback();
+ });
};
Messaging.addMessage = function (data, callback) {
@@ -84,7 +91,6 @@ module.exports = function (Messaging) {
async.apply(Messaging.addRoomToUsers, data.roomId, uids, data.timestamp),
async.apply(Messaging.addMessageToUsers, data.roomId, uids, mid, data.timestamp),
async.apply(Messaging.markUnread, uids, data.roomId),
- async.apply(Messaging.addUsersToRoom, data.uid, [data.uid], data.roomId),
], next);
},
function (results, next) {
diff --git a/src/messaging/rooms.js b/src/messaging/rooms.js
index 219f19513c..6e85e594d0 100644
--- a/src/messaging/rooms.js
+++ b/src/messaging/rooms.js
@@ -229,6 +229,14 @@ module.exports = function (Messaging) {
function (uids, next) {
user.getUsersFields(uids, ['uid', 'username', 'picture', 'status'], next);
},
+ function (users, next) {
+ db.getObjectField('chat:room:' + roomId, 'owner', function (err, ownerId) {
+ next(err, users.map(function (user) {
+ user.isOwner = parseInt(user.uid, 10) === parseInt(ownerId, 10);
+ return user;
+ }));
+ });
+ },
], callback);
};
diff --git a/src/meta.js b/src/meta.js
index cb2a381d6d..9a03c89ee6 100644
--- a/src/meta.js
+++ b/src/meta.js
@@ -39,26 +39,6 @@ Meta.userOrGroupExists = function (slug, callback) {
});
};
-/**
- * Reload deprecated as of v1.1.2+, remove in v2.x
- */
-Meta.reload = function (callback) {
- restart();
- callback();
-};
-
-Meta.restart = function () {
- pubsub.publish('meta:restart', { hostname: os.hostname() });
- restart();
-};
-
-Meta.getSessionTTLSeconds = function () {
- var ttlDays = 60 * 60 * 24 * (parseInt(Meta.config.loginDays, 10) || 0);
- var ttlSeconds = (parseInt(Meta.config.loginSeconds, 10) || 0);
- var ttl = ttlSeconds || ttlDays || 1209600; // Default to 14 days
- return ttl;
-};
-
if (nconf.get('isPrimary') === 'true') {
pubsub.on('meta:restart', function (data) {
if (data.hostname !== os.hostname()) {
@@ -67,6 +47,11 @@ if (nconf.get('isPrimary') === 'true') {
});
}
+Meta.restart = function () {
+ pubsub.publish('meta:restart', { hostname: os.hostname() });
+ restart();
+};
+
function restart() {
if (process.send) {
process.send({
@@ -76,3 +61,10 @@ function restart() {
winston.error('[meta.restart] Could not restart, are you sure NodeBB was started with `./nodebb start`?');
}
}
+
+Meta.getSessionTTLSeconds = function () {
+ var ttlDays = 60 * 60 * 24 * (parseInt(Meta.config.loginDays, 10) || 0);
+ var ttlSeconds = (parseInt(Meta.config.loginSeconds, 10) || 0);
+ var ttl = ttlSeconds || ttlDays || 1209600; // Default to 14 days
+ return ttl;
+};
diff --git a/src/meta/build.js b/src/meta/build.js
index 62399ec2e7..62288dad3a 100644
--- a/src/meta/build.js
+++ b/src/meta/build.js
@@ -134,13 +134,22 @@ function buildTargets(targets, parallel, callback) {
}, callback);
}
-function build(targets, callback) {
+function build(targets, options, callback) {
+ if (!callback && typeof options === 'function') {
+ callback = options;
+ options = {};
+ } else if (!options) {
+ options = {};
+ }
+
if (targets === true) {
targets = allTargets;
} else if (!Array.isArray(targets)) {
targets = targets.split(',');
}
+ var parallel = !nconf.get('series') && !options.series;
+
targets = targets
// get full target name
.map(function (target) {
@@ -200,7 +209,6 @@ function build(targets, callback) {
require('./minifier').maxThreads = threads - 1;
}
- var parallel = !nconf.get('series');
if (parallel) {
winston.info('[build] Building in parallel mode');
} else {
diff --git a/src/meta/css.js b/src/meta/css.js
index cf39d5f8f6..5762c2cf1c 100644
--- a/src/meta/css.js
+++ b/src/meta/css.js
@@ -89,6 +89,7 @@ function getImports(files, prefix, extension, callback) {
function getBundleMetadata(target, callback) {
var paths = [
path.join(__dirname, '../../node_modules'),
+ path.join(__dirname, '../../public/less'),
path.join(__dirname, '../../public/vendor/fontawesome/less'),
];
diff --git a/src/meta/tags.js b/src/meta/tags.js
index 09019884c3..fe95bab6cf 100644
--- a/src/meta/tags.js
+++ b/src/meta/tags.js
@@ -56,7 +56,7 @@ Tags.parse = function (req, data, meta, link, callback) {
var defaultLinks = [{
rel: 'icon',
type: 'image/x-icon',
- href: nconf.get('relative_path') + '/favicon.ico' + (Meta.config['cache-buster'] ? '?' + Meta.config['cache-buster'] : ''),
+ href: nconf.get('relative_path') + nconf.get('upload_url') + '/system/favicon.ico' + (Meta.config['cache-buster'] ? '?' + Meta.config['cache-buster'] : ''),
}, {
rel: 'manifest',
href: nconf.get('relative_path') + '/manifest.json',
@@ -75,31 +75,31 @@ Tags.parse = function (req, data, meta, link, callback) {
if (Meta.config['brand:touchIcon']) {
defaultLinks.push({
rel: 'apple-touch-icon',
- href: nconf.get('relative_path') + '/apple-touch-icon',
+ href: nconf.get('relative_path') + nconf.get('upload_url') + '/system/touchicon-orig.png',
}, {
rel: 'icon',
sizes: '36x36',
- href: nconf.get('relative_path') + '/assets/uploads/system/touchicon-36.png',
+ href: nconf.get('relative_path') + nconf.get('upload_url') + '/system/touchicon-36.png',
}, {
rel: 'icon',
sizes: '48x48',
- href: nconf.get('relative_path') + '/assets/uploads/system/touchicon-48.png',
+ href: nconf.get('relative_path') + nconf.get('upload_url') + '/system/touchicon-48.png',
}, {
rel: 'icon',
sizes: '72x72',
- href: nconf.get('relative_path') + '/assets/uploads/system/touchicon-72.png',
+ href: nconf.get('relative_path') + nconf.get('upload_url') + '/system/touchicon-72.png',
}, {
rel: 'icon',
sizes: '96x96',
- href: nconf.get('relative_path') + '/assets/uploads/system/touchicon-96.png',
+ href: nconf.get('relative_path') + nconf.get('upload_url') + '/system/touchicon-96.png',
}, {
rel: 'icon',
sizes: '144x144',
- href: nconf.get('relative_path') + '/assets/uploads/system/touchicon-144.png',
+ href: nconf.get('relative_path') + nconf.get('upload_url') + '/system/touchicon-144.png',
}, {
rel: 'icon',
sizes: '192x192',
- href: nconf.get('relative_path') + '/assets/uploads/system/touchicon-192.png',
+ href: nconf.get('relative_path') + nconf.get('upload_url') + '/system/touchicon-192.png',
});
}
plugins.fireHook('filter:meta.getLinkTags', { req: req, data: data, links: defaultLinks }, next);
diff --git a/src/meta/templates.js b/src/meta/templates.js
index 1ccf14c8e3..11635dcb50 100644
--- a/src/meta/templates.js
+++ b/src/meta/templates.js
@@ -8,6 +8,7 @@ var path = require('path');
var fs = require('fs');
var nconf = require('nconf');
var _ = require('lodash');
+var Benchpress = require('benchpressjs');
var plugins = require('../plugins');
var file = require('../file');
@@ -113,6 +114,34 @@ function getTemplateFiles(dirs, callback) {
], callback);
}
+function compileTemplate(filename, source, callback) {
+ async.waterfall([
+ function (next) {
+ file.walk(viewsPath, next);
+ },
+ function (paths, next) {
+ paths = _.fromPairs(paths.map(function (p) {
+ var relative = path.relative(viewsPath, p).replace(/\\/g, '/');
+ return [relative, p];
+ }));
+ async.waterfall([
+ function (next) {
+ processImports(paths, filename, source, next);
+ },
+ function (source, next) {
+ Benchpress.precompile(source, {
+ minify: global.env !== 'development',
+ }, next);
+ },
+ function (compiled, next) {
+ fs.writeFile(path.join(viewsPath, filename.replace(/\.tpl$/, '.js')), compiled, next);
+ },
+ ], next);
+ },
+ ], callback);
+}
+Templates.compileTemplate = compileTemplate;
+
function compile(callback) {
callback = callback || function () {};
@@ -144,8 +173,22 @@ function compile(callback) {
next(err, source);
});
},
- function (compiled, next) {
- fs.writeFile(path.join(viewsPath, name), compiled, next);
+ function (imported, next) {
+ async.parallel([
+ function (cb) {
+ fs.writeFile(path.join(viewsPath, name), imported, cb);
+ },
+ function (cb) {
+ Benchpress.precompile(imported, { minify: global.env !== 'development' }, function (err, compiled) {
+ if (err) {
+ cb(err);
+ return;
+ }
+
+ fs.writeFile(path.join(viewsPath, name.replace(/\.tpl$/, '.js')), compiled, cb);
+ });
+ },
+ ], next);
},
], next);
}, next);
diff --git a/src/middleware/header.js b/src/middleware/header.js
index f845d8b4bb..cb29604f95 100644
--- a/src/middleware/header.js
+++ b/src/middleware/header.js
@@ -120,9 +120,7 @@ module.exports = function (middleware) {
banned: async.apply(user.isBanned, req.uid),
banReason: async.apply(user.getBannedReason, req.uid),
- unreadTopicCount: async.apply(topics.getTotalUnread, req.uid),
- unreadNewTopicCount: async.apply(topics.getTotalUnread, req.uid, 'new'),
- unreadWatchedTopicCount: async.apply(topics.getTotalUnread, req.uid, 'watched'),
+ unreadCounts: async.apply(topics.getUnreadTids, { uid: req.uid, count: true }),
unreadChatCount: async.apply(messaging.getUnreadCount, req.uid),
unreadNotificationCount: async.apply(user.notifications.getUnreadCount, req.uid),
}, next);
@@ -137,6 +135,7 @@ module.exports = function (middleware) {
results.user.isGlobalMod = results.isGlobalMod;
results.user.isMod = !!results.isModerator;
results.user.privileges = results.privileges;
+ results.user[results.user.status] = true;
results.user.uid = parseInt(results.user.uid, 10);
results.user.email = String(results.user.email);
@@ -146,12 +145,14 @@ module.exports = function (middleware) {
setBootswatchCSS(templateValues, res.locals.config);
var unreadCount = {
- topic: results.unreadTopicCount || 0,
- newTopic: results.unreadNewTopicCount || 0,
- watchedTopic: results.unreadWatchedTopicCount || 0,
+ topic: results.unreadCounts[''] || 0,
+ newTopic: results.unreadCounts.new || 0,
+ watchedTopic: results.unreadCounts.watched || 0,
+ unrepliedTopic: results.unreadCounts.unreplied || 0,
chat: results.unreadChatCount || 0,
notification: results.unreadNotificationCount || 0,
};
+
Object.keys(unreadCount).forEach(function (key) {
if (unreadCount[key] > 99) {
unreadCount[key] = '99+';
@@ -159,25 +160,18 @@ module.exports = function (middleware) {
});
results.navigation = results.navigation.map(function (item) {
- if (item.originalRoute === '/unread' && results.unreadTopicCount > 0) {
- return Object.assign({}, item, {
- content: unreadCount.topic,
- iconClass: item.iconClass + ' unread-count',
- });
+ function modifyNavItem(item, route, count, content) {
+ if (item && item.originalRoute === route) {
+ item.content = content;
+ if (count > 0) {
+ item.iconClass += ' unread-count';
+ }
+ }
}
- if (item.originalRoute === '/unread/new' && results.unreadNewTopicCount > 0) {
- return Object.assign({}, item, {
- content: unreadCount.newTopic,
- iconClass: item.iconClass + ' unread-count',
- });
- }
- if (item.originalRoute === '/unread/watched' && results.unreadWatchedTopicCount > 0) {
- return Object.assign({}, item, {
- content: unreadCount.watchedTopic,
- iconClass: item.iconClass + ' unread-count',
- });
- }
-
+ modifyNavItem(item, '/unread', results.unreadCounts[''], unreadCount.topic);
+ modifyNavItem(item, '/unread?filter=new', results.unreadCounts.new, unreadCount.newTopic);
+ modifyNavItem(item, '/unread?filter=watched', results.unreadCounts.watched, unreadCount.watchedTopic);
+ modifyNavItem(item, '/unread?filter=unreplied', results.unreadCounts.unreplied, unreadCount.unrepliedTopic);
return item;
});
diff --git a/src/middleware/index.js b/src/middleware/index.js
index 0173ecb3c6..5eba1ed3dc 100644
--- a/src/middleware/index.js
+++ b/src/middleware/index.js
@@ -2,13 +2,11 @@
var async = require('async');
var path = require('path');
-var fs = require('fs');
var csrf = require('csurf');
var validator = require('validator');
var nconf = require('nconf');
var ensureLoggedIn = require('connect-ensure-login');
var toobusy = require('toobusy-js');
-var Benchpress = require('benchpressjs');
var LRU = require('lru-cache');
var plugins = require('../plugins');
@@ -145,8 +143,14 @@ middleware.privateUploads = function (req, res, next) {
if (req.loggedIn || parseInt(meta.config.privateUploads, 10) !== 1) {
return next();
}
+
if (req.path.startsWith(nconf.get('relative_path') + '/assets/uploads/files')) {
- return res.status(403).json('not-allowed');
+ var extensions = (meta.config.privateUploadsExtensions || '').split(',').filter(Boolean);
+ var ext = path.extname(req.path);
+ ext = ext ? ext.replace(/^\./, '') : ext;
+ if (!extensions.length || extensions.includes(ext)) {
+ return res.status(403).json('not-allowed');
+ }
}
next();
};
@@ -201,58 +205,3 @@ middleware.delayLoading = function (req, res, next) {
setTimeout(next, 1000);
};
-
-var viewsDir = nconf.get('views_dir');
-var workingCache = {};
-
-middleware.templatesOnDemand = function (req, res, next) {
- var filePath = req.filePath || path.join(viewsDir, req.path);
- if (!filePath.endsWith('.js')) {
- return next();
- }
- var tplPath = filePath.replace(/\.js$/, '.tpl');
- if (workingCache[filePath]) {
- workingCache[filePath].push(next);
- return;
- }
-
- async.waterfall([
- function (cb) {
- file.exists(filePath, cb);
- },
- function (exists, cb) {
- if (exists) {
- return next();
- }
-
- // need to check here again
- // because compilation could have started since last check
- if (workingCache[filePath]) {
- workingCache[filePath].push(next);
- return;
- }
-
- workingCache[filePath] = [next];
- fs.readFile(tplPath, 'utf8', cb);
- },
- function (source, cb) {
- Benchpress.precompile({
- source: source,
- minify: global.env !== 'development',
- }, cb);
- },
- function (compiled, cb) {
- if (!compiled) {
- return cb(new Error('[[error:templatesOnDemand.compiled-template-empty, ' + tplPath + ']]'));
- }
- fs.writeFile(filePath, compiled, cb);
- },
- ], function (err) {
- var arr = workingCache[filePath];
- workingCache[filePath] = null;
-
- arr.forEach(function (callback) {
- callback(err);
- });
- });
-};
diff --git a/src/middleware/user.js b/src/middleware/user.js
index 17c52e7ca6..3cc69a750d 100644
--- a/src/middleware/user.js
+++ b/src/middleware/user.js
@@ -8,6 +8,8 @@ var user = require('../user');
var privileges = require('../privileges');
var plugins = require('../plugins');
+var auth = require('../routes/authentication');
+
var controllers = {
helpers: require('../controllers/helpers'),
};
@@ -22,7 +24,19 @@ module.exports = function (middleware) {
return plugins.fireHook('action:middleware.authenticate', {
req: req,
res: res,
- next: next,
+ next: function (err) {
+ if (err) {
+ return next(err);
+ }
+
+ auth.setAuthVars(req, res, function () {
+ if (req.loggedIn && req.user && req.user.uid) {
+ return next();
+ }
+
+ controllers.helpers.notAllowed(req, res);
+ });
+ },
});
}
diff --git a/src/navigation/admin.js b/src/navigation/admin.js
index 8bab6d4219..308445db4c 100644
--- a/src/navigation/admin.js
+++ b/src/navigation/admin.js
@@ -15,12 +15,13 @@ pubsub.on('admin:navigation:save', function () {
admin.save = function (data, callback) {
var order = Object.keys(data);
- var items = data.map(function (item) {
+ var items = data.map(function (item, index) {
for (var i in item) {
if (item.hasOwnProperty(i) && typeof item[i] === 'string' && (i === 'title' || i === 'text')) {
item[i] = translator.escape(item[i]);
}
}
+ item.order = order[index];
return JSON.stringify(item);
});
diff --git a/src/notifications.js b/src/notifications.js
index 32adb67fe3..e45625e6e4 100644
--- a/src/notifications.js
+++ b/src/notifications.js
@@ -608,3 +608,5 @@ Notifications.merge = function (notifications, callback) {
callback(err, data.notifications);
});
};
+
+Notifications.async = require('./promisify')(Notifications);
diff --git a/src/posts.js b/src/posts.js
index cd58d81527..080a143f94 100644
--- a/src/posts.js
+++ b/src/posts.js
@@ -321,3 +321,5 @@ Posts.modifyPostByPrivilege = function (post, privileges) {
}
}
};
+
+Posts.async = require('./promisify')(Posts);
diff --git a/src/posts/delete.js b/src/posts/delete.js
index 44ffb12d87..14cd9dd3b1 100644
--- a/src/posts/delete.js
+++ b/src/posts/delete.js
@@ -31,7 +31,7 @@ module.exports = function (Posts) {
postData.cid = topicData.cid;
async.parallel([
function (next) {
- updateTopicTimestamp(topicData, next);
+ topics.updateLastPostTimeFromLastPid(postData.tid, next);
},
function (next) {
db.sortedSetRemove('cid:' + topicData.cid + ':pids', pid, next);
@@ -68,7 +68,7 @@ module.exports = function (Posts) {
postData.cid = topicData.cid;
async.parallel([
function (next) {
- updateTopicTimestamp(topicData, next);
+ topics.updateLastPostTimeFromLastPid(topicData.tid, next);
},
function (next) {
db.sortedSetAdd('cid:' + topicData.cid + ':pids', postData.timestamp, pid, next);
@@ -85,35 +85,6 @@ module.exports = function (Posts) {
], callback);
};
- function updateTopicTimestamp(topicData, callback) {
- var timestamp;
- async.waterfall([
- function (next) {
- topics.getLatestUndeletedPid(topicData.tid, next);
- },
- function (pid, next) {
- if (!parseInt(pid, 10)) {
- return callback();
- }
- Posts.getPostField(pid, 'timestamp', next);
- },
- function (_timestamp, next) {
- timestamp = _timestamp;
- if (!parseInt(timestamp, 10)) {
- return callback();
- }
- topics.updateTimestamp(topicData.tid, timestamp, next);
- },
- function (next) {
- if (parseInt(topicData.pinned, 10) !== 1) {
- db.sortedSetAdd('cid:' + topicData.cid + ':tids', timestamp, topicData.tid, next);
- } else {
- next();
- }
- },
- ], callback);
- }
-
Posts.purge = function (pid, uid, callback) {
async.waterfall([
function (next) {
@@ -194,10 +165,14 @@ module.exports = function (Posts) {
topics.updateTeaser(postData.tid, next);
},
function (next) {
- updateTopicTimestamp(topicData, next);
+ topics.updateLastPostTimeFromLastPid(postData.tid, next);
},
function (next) {
- db.sortedSetIncrBy('cid:' + topicData.cid + ':tids:posts', -1, postData.tid, next);
+ if (parseInt(topicData.pinned, 10) !== 1) {
+ db.sortedSetIncrBy('cid:' + topicData.cid + ':tids:posts', -1, postData.tid, next);
+ } else {
+ next();
+ }
},
function (next) {
db.sortedSetIncrBy('tid:' + postData.tid + ':posters', -1, postData.uid, next);
diff --git a/src/posts/queue.js b/src/posts/queue.js
index 6d8001f2f7..c1fb332450 100644
--- a/src/posts/queue.js
+++ b/src/posts/queue.js
@@ -134,7 +134,7 @@ module.exports = function (Posts) {
db.delete('post:queue:' + id, next);
},
function (next) {
- notifications.rescind('post-queued-' + id, next);
+ notifications.rescind('post-queue-' + id, next);
},
], callback);
};
diff --git a/src/privileges.js b/src/privileges.js
index 9ed1664ced..add204c029 100644
--- a/src/privileges.js
+++ b/src/privileges.js
@@ -49,3 +49,5 @@ require('./privileges/categories')(privileges);
require('./privileges/topics')(privileges);
require('./privileges/posts')(privileges);
require('./privileges/users')(privileges);
+
+privileges.async = require('./promisify')(privileges);
diff --git a/src/privileges/global.js b/src/privileges/global.js
index 554adf0bed..5d0cb88213 100644
--- a/src/privileges/global.js
+++ b/src/privileges/global.js
@@ -21,6 +21,7 @@ module.exports = function (privileges) {
{ name: '[[admin/manage/privileges:search-content]]' },
{ name: '[[admin/manage/privileges:search-users]]' },
{ name: '[[admin/manage/privileges:search-tags]]' },
+ { name: '[[admin/manage/privileges:allow-local-login]]' },
];
privileges.global.userPrivilegeList = [
@@ -32,6 +33,7 @@ module.exports = function (privileges) {
'search:content',
'search:users',
'search:tags',
+ 'local:login',
];
privileges.global.groupPrivilegeList = privileges.global.userPrivilegeList.map(function (privilege) {
@@ -111,6 +113,10 @@ module.exports = function (privileges) {
], callback);
};
+ privileges.global.canGroup = function (privilege, groupName, callback) {
+ groups.isMember(groupName, 'cid:0:privileges:groups:' + privilege, callback);
+ };
+
privileges.global.give = function (privileges, groupName, callback) {
helpers.giveOrRescind(groups.join, privileges, 0, groupName, callback);
};
diff --git a/src/privileges/topics.js b/src/privileges/topics.js
index 0cbf49e7ae..9e387acc77 100644
--- a/src/privileges/topics.js
+++ b/src/privileges/topics.js
@@ -16,7 +16,7 @@ module.exports = function (privileges) {
privileges.topics.get = function (tid, uid, callback) {
var topic;
- var privs = ['topics:reply', 'topics:read', 'topics:tag', 'topics:delete', 'posts:edit', 'posts:history', 'posts:delete', 'posts:view_deleted', 'read'];
+ var privs = ['topics:reply', 'topics:read', 'topics:tag', 'topics:delete', 'posts:edit', 'posts:history', 'posts:delete', 'posts:view_deleted', 'read', 'purge'];
async.waterfall([
async.apply(topics.getTopicFields, tid, ['cid', 'uid', 'locked', 'deleted']),
function (_topic, next) {
@@ -37,6 +37,7 @@ module.exports = function (privileges) {
var isAdminOrMod = results.isAdministrator || results.isModerator;
var editable = isAdminOrMod;
var deletable = isAdminOrMod || (isOwner && privData['topics:delete']);
+ var purge = results.isAdministrator || privData.purge;
plugins.fireHook('filter:privileges.topics.get', {
'topics:reply': (privData['topics:reply'] && !locked && !deleted) || isAdminOrMod,
@@ -51,6 +52,7 @@ module.exports = function (privileges) {
view_thread_tools: editable || deletable,
editable: editable,
deletable: deletable,
+ purge: purge,
view_deleted: isAdminOrMod || isOwner,
isAdminOrMod: isAdminOrMod,
disabled: disabled,
diff --git a/src/promisify.js b/src/promisify.js
new file mode 100644
index 0000000000..9882cf2753
--- /dev/null
+++ b/src/promisify.js
@@ -0,0 +1,37 @@
+'use strict';
+
+// remove once node 6 support is removed
+require('util.promisify/shim')();
+
+var util = require('util');
+var _ = require('lodash');
+
+module.exports = function (theModule, ignoreKeys) {
+ ignoreKeys = ignoreKeys || [];
+ function isCallbackedFunction(func) {
+ if (typeof func !== 'function') {
+ return false;
+ }
+ var str = func.toString().split('\n')[0];
+ return str.includes('callback)');
+ }
+ function promisifyRecursive(module) {
+ if (!module) {
+ return;
+ }
+ var keys = Object.keys(module);
+ keys.forEach(function (key) {
+ if (ignoreKeys.includes(key)) {
+ return;
+ }
+ if (isCallbackedFunction(module[key])) {
+ module[key] = util.promisify(module[key]);
+ } else if (typeof module[key] === 'object') {
+ promisifyRecursive(module[key]);
+ }
+ });
+ }
+ const asyncModule = _.cloneDeep(theModule);
+ promisifyRecursive(asyncModule);
+ return asyncModule;
+};
diff --git a/src/pubsub.js b/src/pubsub.js
index 6b6f12d171..ee6185be20 100644
--- a/src/pubsub.js
+++ b/src/pubsub.js
@@ -1,5 +1,6 @@
'use strict';
+var EventEmitter = require('events');
var nconf = require('nconf');
var real;
@@ -12,17 +13,35 @@ function get() {
var pubsub;
if (nconf.get('isCluster') === 'false') {
- var EventEmitter = require('events');
pubsub = new EventEmitter();
pubsub.publish = pubsub.emit.bind(pubsub);
+ } else if (nconf.get('singleHostCluster')) {
+ pubsub = new EventEmitter();
+ if (!process.send) {
+ pubsub.publish = pubsub.emit.bind(pubsub);
+ } else {
+ pubsub.publish = function (event, data) {
+ process.send({
+ action: 'pubsub',
+ event: event,
+ data: data,
+ });
+ };
+ process.on('message', function (message) {
+ if (message && typeof message === 'object' && message.action === 'pubsub') {
+ pubsub.emit(message.event, message.data);
+ }
+ });
+ }
} else if (nconf.get('redis')) {
pubsub = require('./database/redis/pubsub');
} else if (nconf.get('mongo')) {
pubsub = require('./database/mongo/pubsub');
+ } else if (nconf.get('postgres')) {
+ pubsub = require('./database/postgres/pubsub');
}
real = pubsub;
-
return pubsub;
}
diff --git a/src/routes/accounts.js b/src/routes/accounts.js
index 9febb67391..e5b6198ed1 100644
--- a/src/routes/accounts.js
+++ b/src/routes/accounts.js
@@ -14,6 +14,7 @@ module.exports = function (app, middleware, controllers) {
setupPageRoute(app, '/user/:userslug/following', middleware, middlewares, controllers.accounts.follow.getFollowing);
setupPageRoute(app, '/user/:userslug/followers', middleware, middlewares, controllers.accounts.follow.getFollowers);
+ setupPageRoute(app, '/user/:userslug/categories', middleware, middlewares, controllers.accounts.categories.get);
setupPageRoute(app, '/user/:userslug/posts', middleware, middlewares, controllers.accounts.posts.getPosts);
setupPageRoute(app, '/user/:userslug/topics', middleware, middlewares, controllers.accounts.posts.getTopics);
setupPageRoute(app, '/user/:userslug/best', middleware, middlewares, controllers.accounts.posts.getBestPosts);
@@ -33,8 +34,8 @@ module.exports = function (app, middleware, controllers) {
setupPageRoute(app, '/user/:userslug/uploads', middleware, accountMiddlewares, controllers.accounts.uploads.get);
setupPageRoute(app, '/user/:userslug/consent', middleware, accountMiddlewares, controllers.accounts.consent.get);
setupPageRoute(app, '/user/:userslug/blocks', middleware, accountMiddlewares, controllers.accounts.blocks.getBlocks);
-
- app.delete('/api/user/:userslug/session/:uuid', [middleware.exposeUid, middleware.ensureSelfOrGlobalPrivilege], controllers.accounts.session.revoke);
+ setupPageRoute(app, '/user/:userslug/sessions', middleware, accountMiddlewares, controllers.accounts.sessions.get);
+ app.delete('/api/user/:userslug/session/:uuid', [middleware.exposeUid, middleware.ensureSelfOrGlobalPrivilege], controllers.accounts.sessions.revoke);
setupPageRoute(app, '/notifications', middleware, [middleware.authenticate], controllers.accounts.notifications.get);
setupPageRoute(app, '/user/:userslug/chats/:roomid?', middleware, middlewares, controllers.accounts.chats.get);
diff --git a/src/routes/authentication.js b/src/routes/authentication.js
index 986cc31ed7..8e743cc988 100644
--- a/src/routes/authentication.js
+++ b/src/routes/authentication.js
@@ -19,23 +19,25 @@ Auth.initialize = function (app, middleware) {
app.use(passport.initialize());
app.use(passport.session());
- app.use(function (req, res, next) {
- var isSpider = req.isSpider();
- req.loggedIn = !isSpider && !!req.user;
- if (isSpider) {
- req.uid = -1;
- } else if (req.user) {
- req.uid = parseInt(req.user.uid, 10);
- } else {
- req.uid = 0;
- }
- next();
- });
+ app.use(Auth.setAuthVars);
Auth.app = app;
Auth.middleware = middleware;
};
+Auth.setAuthVars = function (req, res, next) {
+ var isSpider = req.isSpider();
+ req.loggedIn = !isSpider && !!req.user;
+ if (isSpider) {
+ req.uid = -1;
+ } else if (req.user) {
+ req.uid = parseInt(req.user.uid, 10);
+ } else {
+ req.uid = 0;
+ }
+ next();
+};
+
Auth.getLoginStrategies = function () {
return loginStrategies;
};
@@ -85,7 +87,8 @@ Auth.reloadRoutes = function (callback) {
router.post('/register', Auth.middleware.applyCSRF, Auth.middleware.applyBlacklist, controllers.authentication.register);
router.post('/register/complete', Auth.middleware.applyCSRF, Auth.middleware.applyBlacklist, controllers.authentication.registerComplete);
- router.get('/register/abort', controllers.authentication.registerAbort);
+ // router.get('/register/abort', controllers.authentication.registerAbort);
+ router.post('/register/abort', controllers.authentication.registerAbort);
router.post('/login', Auth.middleware.applyCSRF, Auth.middleware.applyBlacklist, controllers.authentication.login);
router.post('/logout', Auth.middleware.applyCSRF, controllers.authentication.logout);
diff --git a/src/routes/feeds.js b/src/routes/feeds.js
index f843e08e7a..07e1cf2379 100644
--- a/src/routes/feeds.js
+++ b/src/routes/feeds.js
@@ -26,6 +26,7 @@ var terms = {
module.exports = function (app, middleware) {
app.get('/topic/:topic_id.rss', middleware.maintenanceMode, generateForTopic);
app.get('/category/:category_id.rss', middleware.maintenanceMode, generateForCategory);
+ app.get('/topics.rss', middleware.maintenanceMode, generateForTopics);
app.get('/recent.rss', middleware.maintenanceMode, generateForRecent);
app.get('/top.rss', middleware.maintenanceMode, generateForTop);
app.get('/top/:term.rss', middleware.maintenanceMode, generateForTop);
@@ -153,7 +154,9 @@ function generateForCategory(req, res, next) {
}
var cid = req.params.category_id;
var category;
-
+ if (!parseInt(cid, 10)) {
+ return next();
+ }
async.waterfall([
function (next) {
async.parallel({
@@ -191,6 +194,31 @@ function generateForCategory(req, res, next) {
], next);
}
+function generateForTopics(req, res, next) {
+ if (parseInt(meta.config['feeds:disableRSS'], 10) === 1) {
+ return controllers404.send404(req, res);
+ }
+
+ async.waterfall([
+ function (next) {
+ if (req.query.token && req.query.uid) {
+ db.getObjectField('user:' + req.query.uid, 'rss_token', next);
+ } else {
+ next(null, null);
+ }
+ },
+ function (token, next) {
+ sendTopicsFeed({
+ uid: token && token === req.query.token ? req.query.uid : req.uid,
+ title: 'Most recently created topics',
+ description: 'A list of topics that have been created recently',
+ feed_url: '/topics.rss',
+ useMainPost: true,
+ }, 'topics:tid', req, res, next);
+ },
+ ], next);
+}
+
function generateForRecent(req, res, next) {
if (parseInt(meta.config['feeds:disableRSS'], 10) === 1) {
return controllers404.send404(req, res);
@@ -205,7 +233,7 @@ function generateForRecent(req, res, next) {
}
},
function (token, next) {
- generateForTopics({
+ sendTopicsFeed({
uid: token && token === req.query.token ? req.query.uid : req.uid,
title: 'Recently Active Topics',
description: 'A list of topics that have been active within the past 24 hours',
@@ -297,7 +325,7 @@ function generateForPopular(req, res, next) {
], next);
}
-function generateForTopics(options, set, req, res, next) {
+function sendTopicsFeed(options, set, req, res, next) {
var start = options.hasOwnProperty('start') ? options.start : 0;
var stop = options.hasOwnProperty('stop') ? options.stop : 19;
async.waterfall([
@@ -326,14 +354,14 @@ function generateTopicsFeed(feedOptions, feedTopics, callback) {
feed.pubDate = new Date(parseInt(feedTopics[0].lastposttime, 10)).toUTCString();
}
- async.each(feedTopics, function (topicData, next) {
+ async.eachSeries(feedTopics, function (topicData, next) {
var feedItem = {
title: utils.stripHTMLTags(topicData.title, utils.tags),
url: nconf.get('url') + '/topic/' + topicData.slug,
date: new Date(parseInt(topicData.lastposttime, 10)).toUTCString(),
};
- if (topicData.teaser && topicData.teaser.user) {
+ if (topicData.teaser && topicData.teaser.user && !feedOptions.useMainPost) {
feedItem.description = topicData.teaser.content;
feedItem.author = topicData.teaser.user.username;
feed.item(feedItem);
@@ -464,7 +492,7 @@ function generateForUserTopics(req, res, callback) {
user.getUserFields(uid, ['uid', 'username'], next);
},
function (userData, next) {
- generateForTopics({
+ sendTopicsFeed({
uid: req.uid,
title: 'Topics by ' + userData.username,
description: 'A list of topics that are posted by ' + userData.username,
@@ -484,7 +512,7 @@ function generateForTag(req, res, next) {
var topicsPerPage = meta.config.topicsPerPage || 20;
var start = Math.max(0, (page - 1) * topicsPerPage);
var stop = start + topicsPerPage - 1;
- generateForTopics({
+ sendTopicsFeed({
uid: req.uid,
title: 'Topics tagged with ' + tag,
description: 'A list of topics that have been tagged with ' + tag,
diff --git a/src/routes/index.js b/src/routes/index.js
index 0b5b37c7b2..1d5d4fe1ee 100644
--- a/src/routes/index.js
+++ b/src/routes/index.js
@@ -152,7 +152,6 @@ module.exports = function (app, middleware, hotswapIds, callback) {
}
app.use(middleware.privateUploads);
- app.use(relativePath + '/assets/templates', middleware.templatesOnDemand);
var statics = [
{ route: '/assets', path: path.join(__dirname, '../../build/public') },
diff --git a/src/search.js b/src/search.js
index 89f8cb66ab..5cb8263bc1 100644
--- a/src/search.js
+++ b/src/search.js
@@ -315,7 +315,7 @@ function filterByTags(posts, hasTags) {
var hasAllTags = false;
if (post && post.topic && Array.isArray(post.topic.tags) && post.topic.tags.length) {
hasAllTags = hasTags.every(function (tag) {
- return post.topic.tags.indexOf(tag) !== -1;
+ return post.topic.tags.includes(tag);
});
}
return hasAllTags;
@@ -370,23 +370,15 @@ function getSearchCids(data, callback) {
return callback(null, []);
}
- if (data.categories.indexOf('all') !== -1) {
- async.waterfall([
- function (next) {
- db.getSortedSetRange('categories:cid', 0, -1, next);
- },
- function (cids, next) {
- privileges.categories.filterCids('read', cids, data.uid, next);
- },
- ], callback);
- return;
+ if (data.categories.includes('all')) {
+ return categories.getCidsByPrivilege('categories:cid', data.uid, 'read', callback);
}
async.waterfall([
function (next) {
async.parallel({
watchedCids: function (next) {
- if (data.categories.indexOf('watched') !== -1) {
+ if (data.categories.includes('watched')) {
user.getWatchedCategories(data.uid, next);
} else {
next(null, []);
diff --git a/src/socket.io/admin.js b/src/socket.io/admin.js
index 3f9cd91666..c13132c973 100644
--- a/src/socket.io/admin.js
+++ b/src/socket.io/admin.js
@@ -178,6 +178,14 @@ SocketAdmin.config.setMultiple = function (socket, data, callback) {
return callback(new Error('[[error:invalid-data]]'));
}
+ var changes = {};
+ Object.keys(data).forEach(function (key) {
+ if (data[key] !== meta.config[key]) {
+ changes[key] = data[key];
+ changes[key + '_old'] = meta.config[key];
+ }
+ });
+
async.waterfall([
function (next) {
meta.configs.setMultiple(data, next);
@@ -194,10 +202,15 @@ SocketAdmin.config.setMultiple = function (socket, data, callback) {
logger.monitorConfig({ io: index.server }, setting);
}
}
- data.type = 'config-change';
- data.uid = socket.uid;
- data.ip = socket.ip;
- events.log(data, next);
+
+ if (Object.keys(changes).length) {
+ changes.type = 'config-change';
+ changes.uid = socket.uid;
+ changes.ip = socket.ip;
+ events.log(changes, next);
+ } else {
+ next();
+ }
},
], callback);
};
diff --git a/src/socket.io/admin/categories.js b/src/socket.io/admin/categories.js
index 6ccc20c873..4bab1330d5 100644
--- a/src/socket.io/admin/categories.js
+++ b/src/socket.io/admin/categories.js
@@ -84,8 +84,10 @@ Categories.setPrivilege = function (socket, data, callback) {
function onSetComplete() {
events.log({
uid: socket.uid,
+ type: 'privilege-change',
ip: socket.ip,
- privilege: data.privilege,
+ privilege: data.privilege.toString(),
+ cid: data.cid,
action: data.set ? 'grant' : 'rescind',
target: data.member,
}, callback);
diff --git a/src/socket.io/admin/user.js b/src/socket.io/admin/user.js
index d8d9a0f282..c15e41fec6 100644
--- a/src/socket.io/admin/user.js
+++ b/src/socket.io/admin/user.js
@@ -2,6 +2,7 @@
var async = require('async');
var validator = require('validator');
+var winston = require('winston');
var db = require('../../database');
var groups = require('../../groups');
@@ -146,37 +147,48 @@ function deleteUsers(socket, uids, method, callback) {
if (!Array.isArray(uids)) {
return callback(new Error('[[error:invalid-data]]'));
}
+ async.waterfall([
+ function (next) {
+ groups.isMembers(uids, 'administrators', next);
+ },
+ function (isMembers, next) {
+ if (isMembers.includes(true)) {
+ return callback(new Error('[[error:cant-delete-other-admins]]'));
+ }
- async.each(uids, function (uid, next) {
- async.waterfall([
- function (next) {
- user.isAdministrator(uid, next);
- },
- function (isAdmin, next) {
- if (isAdmin) {
- return next(new Error('[[error:cant-delete-other-admins]]'));
- }
+ callback();
- method(uid, next);
- },
- function (next) {
- events.log({
- type: 'user-delete',
- uid: socket.uid,
- targetUid: uid,
- ip: socket.ip,
- }, next);
- },
- function (next) {
- plugins.fireHook('action:user.delete', {
- callerUid: socket.uid,
- uid: uid,
- ip: socket.ip,
- });
- next();
- },
- ], next);
- }, callback);
+ async.each(uids, function (uid, next) {
+ async.waterfall([
+ function (next) {
+ method(uid, next);
+ },
+ function (userData, next) {
+ events.log({
+ type: 'user-delete',
+ uid: socket.uid,
+ targetUid: uid,
+ ip: socket.ip,
+ username: userData.username,
+ email: userData.email,
+ }, next);
+ },
+ function (next) {
+ plugins.fireHook('action:user.delete', {
+ callerUid: socket.uid,
+ uid: uid,
+ ip: socket.ip,
+ });
+ next();
+ },
+ ], next);
+ }, next);
+ },
+ ], function (err) {
+ if (err) {
+ winston.error(err);
+ }
+ });
}
User.search = function (socket, data, callback) {
diff --git a/src/socket.io/blacklist.js b/src/socket.io/blacklist.js
index d4f1481508..75b11ce30a 100644
--- a/src/socket.io/blacklist.js
+++ b/src/socket.io/blacklist.js
@@ -5,6 +5,7 @@ var async = require('async');
var user = require('../user');
var meta = require('../meta');
+var events = require('../events');
var SocketBlacklist = module.exports;
@@ -13,21 +14,14 @@ SocketBlacklist.validate = function (socket, data, callback) {
};
SocketBlacklist.save = function (socket, rules, callback) {
- async.waterfall([
- function (next) {
- user.isAdminOrGlobalMod(socket.uid, next);
- },
- function (isAdminOrGlobalMod, next) {
- if (!isAdminOrGlobalMod) {
- return callback(new Error('[[error:no-privileges]]'));
- }
-
- meta.blacklist.save(rules, next);
- },
- ], callback);
+ blacklist(socket, 'save', rules, callback);
};
SocketBlacklist.addRule = function (socket, rule, callback) {
+ blacklist(socket, 'addRule', rule, callback);
+};
+
+function blacklist(socket, method, rule, callback) {
async.waterfall([
function (next) {
user.isAdminOrGlobalMod(socket.uid, next);
@@ -37,7 +31,15 @@ SocketBlacklist.addRule = function (socket, rule, callback) {
return callback(new Error('[[error:no-privileges]]'));
}
- meta.blacklist.addRule(rule, next);
+ meta.blacklist[method](rule, next);
+ },
+ function (next) {
+ events.log({
+ type: 'ip-blacklist-' + method,
+ uid: socket.uid,
+ ip: socket.ip,
+ rule: rule,
+ }, next);
},
], callback);
-};
+}
diff --git a/src/socket.io/categories.js b/src/socket.io/categories.js
index 9dfb285d82..97bdc7d44c 100644
--- a/src/socket.io/categories.js
+++ b/src/socket.io/categories.js
@@ -174,7 +174,17 @@ SocketCategories.ignore = function (socket, cid, callback) {
};
function ignoreOrWatch(fn, socket, cid, callback) {
+ var targetUid = socket.uid;
+ var cids = [parseInt(cid, 10)];
+ if (typeof cid === 'object') {
+ targetUid = cid.uid;
+ cids = [parseInt(cid.cid, 10)];
+ }
+
async.waterfall([
+ function (next) {
+ user.isAdminOrGlobalModOrSelf(socket.uid, targetUid, next);
+ },
function (next) {
db.getSortedSetRange('categories:cid', 0, -1, next);
},
@@ -187,10 +197,7 @@ function ignoreOrWatch(fn, socket, cid, callback) {
c.parentCid = parseInt(c.parentCid, 10);
});
- var cids = [parseInt(cid, 10)];
-
// filter to subcategories of cid
-
var cat;
do {
cat = categoryData.find(function (c) {
@@ -202,11 +209,14 @@ function ignoreOrWatch(fn, socket, cid, callback) {
} while (cat);
async.each(cids, function (cid, next) {
- fn(socket.uid, cid, next);
+ fn(targetUid, cid, next);
}, next);
},
function (next) {
- topics.pushUnreadCount(socket.uid, next);
+ topics.pushUnreadCount(targetUid, next);
+ },
+ function (next) {
+ next(null, cids);
},
], callback);
}
diff --git a/src/socket.io/helpers.js b/src/socket.io/helpers.js
index 4679b73598..1688d88252 100644
--- a/src/socket.io/helpers.js
+++ b/src/socket.io/helpers.js
@@ -31,6 +31,12 @@ SocketHelpers.notifyNew = function (uid, type, result) {
function (uids, next) {
filterTidCidIgnorers(uids, result.posts[0].topic.tid, result.posts[0].topic.cid, next);
},
+ function (uids, next) {
+ user.blocks.filterUids(uid, uids, next);
+ },
+ function (uids, next) {
+ user.blocks.filterUids(result.posts[0].topic.uid, uids, next);
+ },
function (uids, next) {
plugins.fireHook('filter:sockets.sendNewPostToUids', { uidsTo: uids, uidFrom: uid, type: type }, next);
},
@@ -186,9 +192,15 @@ SocketHelpers.upvote = function (data, notification) {
all: function () {
return votes > 0;
},
+ first: function () {
+ return votes === 1;
+ },
everyTen: function () {
return votes > 0 && votes % 10 === 0;
},
+ threshold: function () {
+ return [1, 5, 10, 25].indexOf(votes) !== -1 || (votes >= 50 && votes % 50 === 0);
+ },
logarithmic: function () {
return votes > 1 && Math.log10(votes) % 1 === 0;
},
diff --git a/src/socket.io/index.js b/src/socket.io/index.js
index 884a7e432c..4c772db131 100644
--- a/src/socket.io/index.js
+++ b/src/socket.io/index.js
@@ -27,7 +27,13 @@ Sockets.init = function (server) {
path: nconf.get('relative_path') + '/socket.io',
});
- io.adapter(nconf.get('redis') ? require('../database/redis').socketAdapter() : db.socketAdapter());
+ if (nconf.get('singleHostCluster')) {
+ io.adapter(require('./single-host-cluster'));
+ } else if (nconf.get('redis')) {
+ io.adapter(require('../database/redis').socketAdapter());
+ } else {
+ io.adapter(db.socketAdapter());
+ }
io.use(socketioWildcard);
io.use(authorize);
diff --git a/src/socket.io/posts/edit.js b/src/socket.io/posts/edit.js
index 48f750c2ce..296e3e2cb4 100644
--- a/src/socket.io/posts/edit.js
+++ b/src/socket.io/posts/edit.js
@@ -51,6 +51,7 @@ module.exports = function (SocketPosts) {
type: 'topic-rename',
uid: socket.uid,
ip: socket.ip,
+ tid: result.topic.tid,
oldTitle: validator.escape(String(result.topic.oldTitle)),
newTitle: validator.escape(String(result.topic.title)),
});
diff --git a/src/socket.io/posts/tools.js b/src/socket.io/posts/tools.js
index 9b2da8ee01..c50bed33ca 100644
--- a/src/socket.io/posts/tools.js
+++ b/src/socket.io/posts/tools.js
@@ -39,6 +39,9 @@ module.exports = function (SocketPosts) {
canDelete: function (next) {
privileges.posts.canDelete(data.pid, socket.uid, next);
},
+ canPurge: function (next) {
+ privileges.posts.canPurge(data.pid, socket.uid, next);
+ },
canFlag: function (next) {
privileges.posts.canFlag(data.pid, socket.uid, next);
},
@@ -62,6 +65,7 @@ module.exports = function (SocketPosts) {
posts.selfPost = socket.uid && socket.uid === parseInt(posts.uid, 10);
posts.display_edit_tools = results.canEdit.flag;
posts.display_delete_tools = results.canDelete.flag;
+ posts.display_purge_tools = results.canPurge;
posts.display_flag_tools = socket.uid && !posts.selfPost && results.canFlag.flag;
posts.display_moderator_tools = posts.display_edit_tools || posts.display_delete_tools;
posts.display_move_tools = results.isAdmin || results.isModerator;
@@ -104,6 +108,7 @@ module.exports = function (SocketPosts) {
type: 'post-delete',
uid: socket.uid,
pid: data.pid,
+ tid: postData.tid,
ip: socket.ip,
});
@@ -139,6 +144,7 @@ module.exports = function (SocketPosts) {
type: 'post-restore',
uid: socket.uid,
pid: data.pid,
+ tid: postData.tid,
ip: socket.ip,
});
@@ -200,6 +206,7 @@ module.exports = function (SocketPosts) {
uid: socket.uid,
pid: data.pid,
ip: socket.ip,
+ tid: postData.tid,
title: String(topicData.title),
}, next);
},
diff --git a/src/socket.io/single-host-cluster.js b/src/socket.io/single-host-cluster.js
new file mode 100644
index 0000000000..b2645d6eb9
--- /dev/null
+++ b/src/socket.io/single-host-cluster.js
@@ -0,0 +1,67 @@
+'use strict';
+
+var Client = {
+ sendMessage: function (channel, message) {
+ process.send({
+ action: 'socket.io',
+ channel: channel,
+ message: message,
+ });
+ },
+ trigger: function (channel, message) {
+ Client.message.concat(Client.pmessage).forEach(function (callback) {
+ setImmediate(function () {
+ callback.call(Client, channel, message);
+ });
+ });
+ },
+ publish: function (channel, message) {
+ Client.sendMessage(channel, message);
+ },
+ // we don't actually care about which channels we're subscribed to
+ subscribe: function () {},
+ psubscribe: function () {},
+ unsubscribe: function () {},
+ unpsubscribe: function () {},
+ message: [],
+ pmessage: [],
+ on: function (event, callback) {
+ if (event !== 'message' && event !== 'pmessage') {
+ return;
+ }
+ Client[event].push(callback);
+ },
+ off: function (event, callback) {
+ if (event !== 'message' && event !== 'pmessage') {
+ return;
+ }
+ if (callback) {
+ Client[event] = Client[event].filter(function (c) {
+ return c !== callback;
+ });
+ } else {
+ Client[event] = [];
+ }
+ },
+};
+
+process.on('message', function (message) {
+ if (message && typeof message === 'object' && message.action === 'socket.io') {
+ Client.trigger(message.channel, message.message);
+ }
+});
+
+var adapter = require('socket.io-adapter-cluster')({
+ client: Client,
+});
+// Otherwise, every node thinks it is the master node and ignores messages
+// because they are from "itself".
+Object.defineProperty(adapter.prototype, 'id', {
+ get: function () {
+ return process.pid;
+ },
+ set: function () {
+ // ignore
+ },
+});
+module.exports = adapter;
diff --git a/src/socket.io/topics.js b/src/socket.io/topics.js
index 89fca006c5..bbeda1bee5 100644
--- a/src/socket.io/topics.js
+++ b/src/socket.io/topics.js
@@ -8,6 +8,7 @@ var websockets = require('./index');
var user = require('../user');
var meta = require('../meta');
var apiController = require('../controllers/api');
+var privileges = require('../privileges');
var socketHelpers = require('./helpers');
var SocketTopics = module.exports;
@@ -62,7 +63,18 @@ function postTopic(socket, data, callback) {
}
SocketTopics.postcount = function (socket, tid, callback) {
- topics.getTopicField(tid, 'postcount', callback);
+ async.waterfall([
+ function (next) {
+ privileges.topics.can('read', tid, socket.uid, next);
+ },
+ function (canRead, next) {
+ if (!canRead) {
+ return next(new Error('[[no-privileges]]'));
+ }
+
+ topics.getTopicField(tid, 'postcount', next);
+ },
+ ], callback);
};
SocketTopics.bookmark = function (socket, data, callback) {
diff --git a/src/socket.io/user.js b/src/socket.io/user.js
index 5401ccca44..ca21d0a38b 100644
--- a/src/socket.io/user.js
+++ b/src/socket.io/user.js
@@ -37,8 +37,14 @@ SocketUser.deleteAccount = function (socket, data, callback) {
async.waterfall([
function (next) {
- user.isPasswordCorrect(socket.uid, data.password, function (err, ok) {
- next(err || !ok ? new Error('[[error:invalid-password]]') : undefined);
+ user.hasPassword(socket.uid, next);
+ },
+ function (hasPassword, next) {
+ if (!hasPassword) {
+ return next();
+ }
+ user.isPasswordCorrect(socket.uid, data.password, socket.ip, function (err, ok) {
+ next(err || (!ok ? new Error('[[error:invalid-password]]') : undefined));
});
},
function (next) {
@@ -50,7 +56,7 @@ SocketUser.deleteAccount = function (socket, data, callback) {
}
user.deleteAccount(socket.uid, next);
},
- function (next) {
+ function (userData, next) {
require('./index').server.sockets.emit('event:user_status_change', { uid: socket.uid, status: 'offline' });
events.log({
@@ -58,18 +64,12 @@ SocketUser.deleteAccount = function (socket, data, callback) {
uid: socket.uid,
targetUid: socket.uid,
ip: socket.ip,
+ username: userData.username,
+ email: userData.email,
});
next();
},
- ], function (err) {
- if (err) {
- return setTimeout(function () {
- callback(err);
- }, 2500);
- }
-
- callback();
- });
+ ], callback);
};
SocketUser.emailExists = function (socket, data, callback) {
@@ -263,12 +263,19 @@ SocketUser.getUnreadCounts = function (socket, data, callback) {
return callback(null, {});
}
async.parallel({
- unreadTopicCount: async.apply(topics.getTotalUnread, socket.uid),
- unreadNewTopicCount: async.apply(topics.getTotalUnread, socket.uid, 'new'),
- unreadWatchedTopicCount: async.apply(topics.getTotalUnread, socket.uid, 'watched'),
+ unreadCounts: async.apply(topics.getUnreadTids, { uid: socket.uid, count: true }),
unreadChatCount: async.apply(messaging.getUnreadCount, socket.uid),
unreadNotificationCount: async.apply(user.notifications.getUnreadCount, socket.uid),
- }, callback);
+ }, function (err, results) {
+ if (err) {
+ return callback(err);
+ }
+ results.unreadTopicCount = results.unreadCounts[''];
+ results.unreadNewTopicCount = results.unreadCounts.new;
+ results.unreadWatchedTopicCount = results.unreadCounts.watched;
+ results.unreadUnrepliedTopicCount = results.unreadCounts.unreplied;
+ callback(null, results);
+ });
};
SocketUser.invite = function (socket, email, callback) {
@@ -290,24 +297,26 @@ SocketUser.invite = function (socket, email, callback) {
if (registrationType === 'admin-invite-only' && !isAdmin) {
return next(new Error('[[error:no-privileges]]'));
}
-
var max = parseInt(meta.config.maximumInvites, 10);
- if (!max) {
- return user.sendInvitationEmail(socket.uid, email, callback);
- }
+ email = email.split(',').map(email => email.trim()).filter(Boolean);
+ async.eachSeries(email, function (email, next) {
+ async.waterfall([
+ function (next) {
+ if (max) {
+ user.getInvitesNumber(socket.uid, next);
+ } else {
+ next(null, 0);
+ }
+ },
+ function (invites, next) {
+ if (!isAdmin && max && invites >= max) {
+ return next(new Error('[[error:invite-maximum-met, ' + invites + ', ' + max + ']]'));
+ }
- async.waterfall([
- function (next) {
- user.getInvitesNumber(socket.uid, next);
- },
- function (invites, next) {
- if (!isAdmin && invites >= max) {
- return next(new Error('[[error:invite-maximum-met, ' + invites + ', ' + max + ']]'));
- }
-
- user.sendInvitationEmail(socket.uid, email, next);
- },
- ], next);
+ user.sendInvitationEmail(socket.uid, email, next);
+ },
+ ], next);
+ }, next);
},
], callback);
};
diff --git a/src/socket.io/user/ban.js b/src/socket.io/user/ban.js
index 66f98eb061..b53398791f 100644
--- a/src/socket.io/user/ban.js
+++ b/src/socket.io/user/ban.js
@@ -3,6 +3,7 @@
var async = require('async');
var winston = require('winston');
+var db = require('../../database');
var user = require('../../user');
var meta = require('../../meta');
var websockets = require('../index');
@@ -22,7 +23,7 @@ module.exports = function (SocketUser) {
toggleBan(socket.uid, data.uids, function (uid, next) {
async.waterfall([
function (next) {
- banUser(uid, data.until || 0, data.reason || '', next);
+ banUser(socket.uid, uid, data.until || 0, data.reason || '', next);
},
function (next) {
events.log({
@@ -30,6 +31,7 @@ module.exports = function (SocketUser) {
uid: socket.uid,
targetUid: uid,
ip: socket.ip,
+ reason: data.reason || undefined,
}, next);
},
function (next) {
@@ -38,6 +40,7 @@ module.exports = function (SocketUser) {
ip: socket.ip,
uid: uid,
until: data.until > 0 ? data.until : undefined,
+ reason: data.reason || undefined,
});
next();
},
@@ -92,7 +95,7 @@ module.exports = function (SocketUser) {
], callback);
}
- function banUser(uid, until, reason, callback) {
+ function banUser(callerUid, uid, until, reason, callback) {
async.waterfall([
function (next) {
user.isAdministrator(uid, next);
@@ -123,6 +126,9 @@ module.exports = function (SocketUser) {
function (next) {
user.ban(uid, until, reason, next);
},
+ function (banData, next) {
+ db.setObjectField('uid:' + uid + ':ban:' + banData.timestamp, 'fromUid', callerUid, next);
+ },
function (next) {
if (!reason) {
return translator.translate('[[user:info.banned-no-reason]]', function (translated) {
diff --git a/src/socket.io/user/profile.js b/src/socket.io/user/profile.js
index 6cf76be9ee..857ac4ddf8 100644
--- a/src/socket.io/user/profile.js
+++ b/src/socket.io/user/profile.js
@@ -15,7 +15,7 @@ module.exports = function (SocketUser) {
async.waterfall([
function (next) {
- isPrivilegedOrSelfAndPasswordMatch(socket.uid, data, next);
+ isPrivilegedOrSelfAndPasswordMatch(socket, data, next);
},
function (next) {
SocketUser.updateProfile(socket, data, next);
@@ -72,26 +72,19 @@ module.exports = function (SocketUser) {
], callback);
};
- function isPrivilegedOrSelfAndPasswordMatch(uid, data, callback) {
+ function isPrivilegedOrSelfAndPasswordMatch(socket, data, callback) {
+ const uid = socket.uid;
+ const isSelf = parseInt(uid, 10) === parseInt(data.uid, 10);
+
async.waterfall([
function (next) {
async.parallel({
isAdmin: async.apply(user.isAdministrator, uid),
isTargetAdmin: async.apply(user.isAdministrator, data.uid),
isGlobalMod: async.apply(user.isGlobalModerator, uid),
- hasPassword: async.apply(user.hasPassword, data.uid),
- passwordMatch: function (next) {
- if (data.password) {
- user.isPasswordCorrect(data.uid, data.password, next);
- } else {
- next(null, false);
- }
- },
}, next);
},
function (results, next) {
- var isSelf = parseInt(uid, 10) === parseInt(data.uid, 10);
-
if (results.isTargetAdmin && !results.isAdmin) {
return next(new Error('[[error:no-privileges]]'));
}
@@ -100,6 +93,17 @@ module.exports = function (SocketUser) {
return next(new Error('[[error:no-privileges]]'));
}
+ async.parallel({
+ hasPassword: async.apply(user.hasPassword, data.uid),
+ passwordMatch: function (next) {
+ if (data.password) {
+ user.isPasswordCorrect(data.uid, data.password, socket.ip, next);
+ } else {
+ next(null, false);
+ }
+ },
+ }, next);
+ }, function (results, next) {
if (isSelf && results.hasPassword && !results.passwordMatch) {
return next(new Error('[[error:invalid-password]]'));
}
@@ -119,7 +123,7 @@ module.exports = function (SocketUser) {
}
async.waterfall([
function (next) {
- user.changePassword(socket.uid, data, next);
+ user.changePassword(socket.uid, Object.assign(data, { ip: socket.ip }), next);
},
function (next) {
events.log({
@@ -201,24 +205,29 @@ module.exports = function (SocketUser) {
};
SocketUser.toggleBlock = function (socket, data, callback) {
- let current;
+ let isBlocked;
async.waterfall([
function (next) {
- user.blocks.can(socket.uid, data.blockerUid, data.blockeeUid, next);
+ async.parallel({
+ can: function (next) {
+ user.blocks.can(socket.uid, data.blockerUid, data.blockeeUid, next);
+ },
+ is: function (next) {
+ user.blocks.is(data.blockeeUid, data.blockerUid, next);
+ },
+ }, next);
},
- function (can, next) {
- if (!can) {
+ function (results, next) {
+ isBlocked = results.is;
+ if (!results.can && !isBlocked) {
return next(new Error('[[error:cannot-block-privileged]]'));
}
- user.blocks.is(data.blockeeUid, data.blockerUid, next);
- },
- function (is, next) {
- current = is;
- user.blocks[is ? 'remove' : 'add'](data.blockeeUid, data.blockerUid, next);
+
+ user.blocks[isBlocked ? 'remove' : 'add'](data.blockeeUid, data.blockerUid, next);
},
], function (err) {
- callback(err, !current);
+ callback(err, !isBlocked);
});
};
};
diff --git a/src/start.js b/src/start.js
index 427a58830b..d16e4d3a34 100644
--- a/src/start.js
+++ b/src/start.js
@@ -118,19 +118,6 @@ function addProcessHandlers() {
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
process.on('SIGHUP', restart);
- process.on('message', function (message) {
- if (typeof message !== 'object') {
- return;
- }
- var meta = require('./meta');
-
- switch (message.action) {
- case 'reload':
- meta.reload();
- break;
- }
- });
-
process.on('uncaughtException', function (err) {
winston.error(err);
diff --git a/src/topics.js b/src/topics.js
index 5214b6a716..daf3db8790 100644
--- a/src/topics.js
+++ b/src/topics.js
@@ -114,7 +114,7 @@ Topics.getTopicsByTids = function (tids, uid, callback) {
user.getMultipleUserSettings(uids, next);
},
categories: function (next) {
- categories.getCategoriesFields(cids, ['cid', 'name', 'slug', 'icon', 'image', 'bgColor', 'color', 'disabled'], next);
+ categories.getCategoriesFields(cids, ['cid', 'name', 'slug', 'icon', 'image', 'imageClass', 'bgColor', 'color', 'disabled'], next);
},
hasRead: function (next) {
Topics.hasReadTopics(tids, uid, next);
@@ -368,3 +368,5 @@ Topics.search = function (tid, term, callback) {
callback(err, Array.isArray(pids) ? pids : []);
});
};
+
+Topics.async = require('./promisify')(Topics);
diff --git a/src/topics/create.js b/src/topics/create.js
index fcd166dfde..4a0d1be942 100644
--- a/src/topics/create.js
+++ b/src/topics/create.js
@@ -298,7 +298,7 @@ module.exports = function (Topics) {
posts.getUserInfoForPosts([postData.uid], uid, next);
},
topicInfo: function (next) {
- Topics.getTopicFields(tid, ['tid', 'title', 'slug', 'cid', 'postcount', 'mainPid'], next);
+ Topics.getTopicFields(tid, ['tid', 'uid', 'title', 'slug', 'cid', 'postcount', 'mainPid'], next);
},
parents: function (next) {
Topics.addParentPosts([postData], next);
diff --git a/src/topics/data.js b/src/topics/data.js
index 36a0d36206..80f4969103 100644
--- a/src/topics/data.js
+++ b/src/topics/data.js
@@ -92,12 +92,19 @@ module.exports = function (Topics) {
if (!topic) {
return;
}
+ if (topic.hasOwnProperty('title')) {
+ topic.titleRaw = topic.title;
+ topic.title = String(topic.title);
+ }
- topic.titleRaw = topic.title;
- topic.title = String(topic.title);
escapeTitle(topic);
- topic.timestampISO = utils.toISOString(topic.timestamp);
- topic.lastposttimeISO = utils.toISOString(topic.lastposttime);
+ if (topic.hasOwnProperty('timestamp')) {
+ topic.timestampISO = utils.toISOString(topic.timestamp);
+ }
+ if (topic.hasOwnProperty('lastposttime')) {
+ topic.lastposttimeISO = utils.toISOString(topic.lastposttime);
+ }
+
if (topic.hasOwnProperty('upvotes')) {
topic.upvotes = parseInt(topic.upvotes, 10) || 0;
}
diff --git a/src/topics/fork.js b/src/topics/fork.js
index 75591207a3..4ac4dfb113 100644
--- a/src/topics/fork.js
+++ b/src/topics/fork.js
@@ -68,7 +68,7 @@ module.exports = function (Topics) {
}, next);
},
function (next) {
- Topics.updateTimestamp(tid, Date.now(), next);
+ Topics.updateLastPostTime(tid, Date.now(), next);
},
function (next) {
plugins.fireHook('action:topic.fork', { tid: tid, fromTid: fromTid, uid: uid });
@@ -106,13 +106,7 @@ module.exports = function (Topics) {
function (next) {
async.parallel([
function (next) {
- updateCategoryPostCount(postData.tid, tid, next);
- },
- function (next) {
- Topics.decreasePostCount(postData.tid, next);
- },
- function (next) {
- Topics.increasePostCount(tid, next);
+ updateCategory(postData, tid, next);
},
function (next) {
posts.setPostField(pid, 'tid', tid, next);
@@ -124,8 +118,8 @@ module.exports = function (Topics) {
},
function (results, next) {
async.parallel([
- async.apply(updateRecentTopic, tid),
- async.apply(updateRecentTopic, postData.tid),
+ async.apply(Topics.updateLastPostTimeFromLastPid, tid),
+ async.apply(Topics.updateLastPostTimeFromLastPid, postData.tid),
], function (err) {
next(err);
});
@@ -137,40 +131,42 @@ module.exports = function (Topics) {
], callback);
};
- function updateCategoryPostCount(oldTid, tid, callback) {
+ function updateCategory(postData, toTid, callback) {
+ var topicData;
async.waterfall([
function (next) {
- Topics.getTopicsFields([oldTid, tid], ['cid'], next);
+ Topics.getTopicsFields([postData.tid, toTid], ['cid', 'pinned'], next);
},
- function (topicData, next) {
+ function (_topicData, next) {
+ topicData = _topicData;
if (!topicData[0].cid || !topicData[1].cid) {
return callback();
}
+ var tasks = [];
+ if (parseInt(topicData[0].pinned, 10) !== 1) {
+ tasks.push(async.apply(db.sortedSetIncrBy, 'cid:' + topicData[0].cid + ':tids:posts', -1, postData.tid));
+ }
+ if (parseInt(topicData[1].pinned, 10) !== 1) {
+ tasks.push(async.apply(db.sortedSetIncrBy, 'cid:' + topicData[1].cid + ':tids:posts', 1, toTid));
+ } else {
+ next();
+ }
+ async.series(tasks, function (err) {
+ next(err);
+ });
+ },
+ function (next) {
if (parseInt(topicData[0].cid, 10) === parseInt(topicData[1].cid, 10)) {
return callback();
}
+
async.parallel([
async.apply(db.incrObjectFieldBy, 'category:' + topicData[0].cid, 'post_count', -1),
async.apply(db.incrObjectFieldBy, 'category:' + topicData[1].cid, 'post_count', 1),
+ async.apply(db.sortedSetRemove, 'cid:' + topicData[0].cid + ':pids', postData.pid),
+ async.apply(db.sortedSetAdd, 'cid:' + topicData[1].cid + ':pids', postData.timestamp, postData.pid),
], next);
},
], callback);
}
-
- function updateRecentTopic(tid, callback) {
- async.waterfall([
- function (next) {
- Topics.getLatestUndeletedPid(tid, next);
- },
- function (pid, next) {
- if (!pid) {
- return callback();
- }
- posts.getPostField(pid, 'timestamp', next);
- },
- function (timestamp, next) {
- Topics.updateTimestamp(tid, timestamp, next);
- },
- ], callback);
- }
};
diff --git a/src/topics/posts.js b/src/topics/posts.js
index a3bc448d75..7e1140e5fa 100644
--- a/src/topics/posts.js
+++ b/src/topics/posts.js
@@ -16,10 +16,7 @@ module.exports = function (Topics) {
Topics.onNewPostMade = function (postData, callback) {
async.series([
function (next) {
- Topics.increasePostCount(postData.tid, next);
- },
- function (next) {
- Topics.updateTimestamp(postData.tid, postData.timestamp, next);
+ Topics.updateLastPostTime(postData.tid, postData.timestamp, next);
},
function (next) {
Topics.addPostToTopic(postData.tid, postData, next);
@@ -287,6 +284,9 @@ module.exports = function (Topics) {
});
}
},
+ function (next) {
+ Topics.increasePostCount(tid, next);
+ },
function (next) {
db.sortedSetIncrBy('tid:' + tid + ':posters', 1, postData.uid, next);
},
@@ -304,6 +304,9 @@ module.exports = function (Topics) {
'tid:' + tid + ':posts:votes',
], postData.pid, next);
},
+ function (next) {
+ Topics.decreasePostCount(tid, next);
+ },
function (next) {
db.sortedSetIncrBy('tid:' + tid + ':posters', -1, postData.uid, next);
},
diff --git a/src/topics/recent.js b/src/topics/recent.js
index 1556e081bc..eb964d259a 100644
--- a/src/topics/recent.js
+++ b/src/topics/recent.js
@@ -3,12 +3,14 @@
'use strict';
var async = require('async');
+var winston = require('winston');
var db = require('../database');
var plugins = require('../plugins');
var privileges = require('../privileges');
var user = require('../user');
var meta = require('../meta');
+var posts = require('../posts');
module.exports = function (Topics) {
var terms = {
@@ -26,6 +28,7 @@ module.exports = function (Topics) {
};
params.term = params.term || 'alltime';
+ params.sort = params.sort || 'recent';
if (params.hasOwnProperty('cids') && params.cids && !Array.isArray(params.cids)) {
params.cids = [params.cids];
}
@@ -226,34 +229,58 @@ module.exports = function (Topics) {
db.getSortedSetRevRangeByScore(set, start, count, '+inf', Date.now() - since, callback);
};
- Topics.updateTimestamp = function (tid, timestamp, callback) {
- async.parallel([
+ Topics.updateLastPostTimeFromLastPid = function (tid, callback) {
+ async.waterfall([
function (next) {
- var topicData;
- async.waterfall([
- function (next) {
- Topics.getTopicFields(tid, ['cid', 'deleted'], next);
- },
- function (_topicData, next) {
- topicData = _topicData;
- db.sortedSetAdd('cid:' + topicData.cid + ':tids:lastposttime', timestamp, tid, next);
- },
- function (next) {
- if (parseInt(topicData.deleted, 10) === 1) {
- return next();
- }
- Topics.updateRecent(tid, timestamp, next);
- },
- ], next);
+ Topics.getLatestUndeletedPid(tid, next);
+ },
+ function (pid, next) {
+ if (!parseInt(pid, 10)) {
+ return callback();
+ }
+ posts.getPostField(pid, 'timestamp', next);
+ },
+ function (timestamp, next) {
+ if (!parseInt(timestamp, 10)) {
+ return callback();
+ }
+ Topics.updateLastPostTime(tid, timestamp, next);
+ },
+ ], callback);
+ };
+
+ Topics.updateLastPostTime = function (tid, lastposttime, callback) {
+ async.waterfall([
+ function (next) {
+ Topics.setTopicField(tid, 'lastposttime', lastposttime, next);
},
function (next) {
- Topics.setTopicField(tid, 'lastposttime', timestamp, next);
+ Topics.getTopicFields(tid, ['cid', 'deleted', 'pinned'], next);
+ },
+ function (topicData, next) {
+ var tasks = [
+ async.apply(db.sortedSetAdd, 'cid:' + topicData.cid + ':tids:lastposttime', lastposttime, tid),
+ ];
+
+ if (parseInt(topicData.deleted, 10) !== 1) {
+ tasks.push(async.apply(Topics.updateRecent, tid, lastposttime));
+ }
+
+ if (parseInt(topicData.pinned, 10) !== 1) {
+ tasks.push(async.apply(db.sortedSetAdd, 'cid:' + topicData.cid + ':tids', lastposttime, tid));
+ }
+ async.series(tasks, next);
},
], function (err) {
callback(err);
});
};
+ Topics.updateTimestamp = function (tid, lastposttime, callback) {
+ winston.warn('[deprecated] Topics.updateTimestamp is deprecated please use Topics.updateLastPostTime');
+ Topics.updateLastPostTime(tid, lastposttime, callback);
+ };
+
Topics.updateRecent = function (tid, timestamp, callback) {
callback = callback || function () {};
diff --git a/src/topics/teaser.js b/src/topics/teaser.js
index 508bd51bc8..2a636b0702 100644
--- a/src/topics/teaser.js
+++ b/src/topics/teaser.js
@@ -5,6 +5,7 @@ var async = require('async');
var _ = require('lodash');
var winston = require('winston');
+var db = require('../database');
var meta = require('../meta');
var user = require('../user');
var posts = require('../posts');
@@ -57,9 +58,14 @@ module.exports = function (Topics) {
function (next) {
posts.getPostsFields(teaserPids, ['pid', 'uid', 'timestamp', 'tid', 'content'], next);
},
- async.apply(user.blocks.filter, uid),
function (_postData, next) {
- postData = _postData;
+ _postData = _postData.filter(function (post) {
+ return post && parseInt(post.pid, 10);
+ });
+ handleBlocks(uid, _postData, next);
+ },
+ function (_postData, next) {
+ postData = _postData.filter(Boolean);
var uids = _.uniq(postData.map(function (post) {
return post.uid;
}));
@@ -72,7 +78,6 @@ module.exports = function (Topics) {
users[user.uid] = user;
});
-
async.each(postData, function (post, next) {
// If the post author isn't represented in the retrieved users' data, then it means they were deleted, assume guest.
if (!users.hasOwnProperty(post.uid)) {
@@ -107,6 +112,58 @@ module.exports = function (Topics) {
], callback);
};
+ function handleBlocks(uid, teasers, callback) {
+ user.blocks.list(uid, function (err, blockedUids) {
+ if (err || !blockedUids.length) {
+ return callback(err, teasers);
+ }
+ async.mapSeries(teasers, function (postData, nextPost) {
+ if (blockedUids.includes(parseInt(postData.uid, 10))) {
+ getPreviousNonBlockedPost(postData, blockedUids, nextPost);
+ } else {
+ setImmediate(nextPost, null, postData);
+ }
+ }, callback);
+ });
+ }
+
+ function getPreviousNonBlockedPost(postData, blockedUids, callback) {
+ let isBlocked = false;
+ let prevPost = postData;
+ const postsPerIteration = 5;
+ let start = 0;
+ let stop = start + postsPerIteration - 1;
+
+ async.doWhilst(function (next) {
+ async.waterfall([
+ function (next) {
+ db.getSortedSetRevRange('tid:' + postData.tid + ':posts', start, stop, next);
+ },
+ function (pids, next) {
+ if (!pids.length) {
+ return callback(null, null);
+ }
+
+ posts.getPostsFields(pids, ['pid', 'uid', 'timestamp', 'tid', 'content'], next);
+ },
+ function (prevPosts, next) {
+ isBlocked = prevPosts.every(function (post) {
+ const isPostBlocked = blockedUids.includes(parseInt(post.uid, 10));
+ prevPost = !isPostBlocked ? post : prevPost;
+ return isPostBlocked;
+ });
+ start += postsPerIteration;
+ stop = start + postsPerIteration - 1;
+ next();
+ },
+ ], next);
+ }, function () {
+ return isBlocked && prevPost && prevPost.pid;
+ }, function (err) {
+ callback(err, prevPost);
+ });
+ }
+
Topics.getTeasersByTids = function (tids, uid, callback) {
if (typeof uid === 'function') {
winston.warn('[Topics.getTeasersByTids] this usage is deprecated please provide uid');
diff --git a/src/topics/thumb.js b/src/topics/thumb.js
index cfd13f3fb8..17d2806810 100644
--- a/src/topics/thumb.js
+++ b/src/topics/thumb.js
@@ -49,7 +49,6 @@ module.exports = function (Topics) {
var size = parseInt(meta.config.topicThumbSize, 10) || 120;
image.resizeImage({
path: pathToUpload,
- extension: path.extname(pathToUpload),
width: size,
height: size,
}, next);
diff --git a/src/topics/unread.js b/src/topics/unread.js
index 86d3cac56a..c8b3552af5 100644
--- a/src/topics/unread.js
+++ b/src/topics/unread.js
@@ -6,6 +6,7 @@ var _ = require('lodash');
var db = require('../database');
var user = require('../user');
+var posts = require('../posts');
var notifications = require('../notifications');
var categories = require('../categories');
var privileges = require('../privileges');
@@ -19,8 +20,8 @@ module.exports = function (Topics) {
callback = filter;
filter = '';
}
- Topics.getUnreadTids({ cid: 0, uid: uid, filter: filter }, function (err, tids) {
- callback(err, Array.isArray(tids) ? tids.length : 0);
+ Topics.getUnreadTids({ cid: 0, uid: uid, count: true }, function (err, counts) {
+ callback(err, counts && counts[filter]);
});
};
@@ -65,10 +66,18 @@ module.exports = function (Topics) {
Topics.getUnreadTids = function (params, callback) {
var uid = parseInt(params.uid, 10);
- if (uid === 0) {
- return callback(null, []);
+ var counts = {
+ '': 0,
+ new: 0,
+ watched: 0,
+ unreplied: 0,
+ };
+ if (uid <= 0) {
+ return callback(null, params.count ? counts : []);
}
+ params.filter = params.filter || '';
+
var cutoff = params.cutoff || Topics.unreadCutoff();
if (params.cid && !Array.isArray(params.cid)) {
@@ -94,106 +103,210 @@ module.exports = function (Topics) {
},
function (results, next) {
if (results.recentTids && !results.recentTids.length && !results.tids_unread.length) {
- return callback(null, []);
+ return callback(null, params.count ? counts : []);
}
- var userRead = {};
- results.userScores.forEach(function (userItem) {
- userRead[userItem.value] = userItem.score;
- });
-
- results.recentTids = results.recentTids.concat(results.tids_unread);
- results.recentTids.sort(function (a, b) {
- return b.score - a.score;
- });
-
- var tids = results.recentTids.filter(function (recentTopic) {
- if (results.ignoredTids.indexOf(recentTopic.value.toString()) !== -1) {
- return false;
- }
- switch (params.filter) {
- case 'new':
- return !userRead[recentTopic.value];
- default:
- return !userRead[recentTopic.value] || recentTopic.score > userRead[recentTopic.value];
- }
- }).map(function (topic) {
- return topic.value;
- });
-
- tids = _.uniq(tids);
-
- if (params.filter === 'watched') {
- Topics.filterWatchedTids(tids, uid, next);
- } else if (params.filter === 'unreplied') {
- Topics.filterUnrepliedTids(tids, next);
- } else {
- next(null, tids);
- }
+ filterTopics(params, results, next);
},
- function (tids, next) {
- tids = tids.slice(0, 200);
-
- filterTopics(uid, tids, params.cid, params.filter, next);
- },
- function (tids, next) {
+ function (data, next) {
plugins.fireHook('filter:topics.getUnreadTids', {
uid: uid,
- tids: tids,
+ tids: data.tids,
+ counts: data.counts,
+ tidsByFilter: data.tidsByFilter,
cid: params.cid,
filter: params.filter,
}, next);
},
function (results, next) {
- next(null, results.tids);
+ next(null, params.count ? results.counts : results.tids);
},
], callback);
};
+ function filterTopics(params, results, callback) {
+ const counts = {
+ '': 0,
+ new: 0,
+ watched: 0,
+ unreplied: 0,
+ };
+
+ const tidsByFilter = {
+ '': [],
+ new: [],
+ watched: [],
+ unreplied: [],
+ };
+
+ var userRead = {};
+ results.userScores.forEach(function (userItem) {
+ userRead[userItem.value] = userItem.score;
+ });
+
+ results.recentTids = results.recentTids.concat(results.tids_unread);
+ results.recentTids.sort(function (a, b) {
+ return b.score - a.score;
+ });
+
+ var tids = results.recentTids.filter(function (recentTopic) {
+ if (results.ignoredTids.includes(String(recentTopic.value))) {
+ return false;
+ }
+ return !userRead[recentTopic.value] || recentTopic.score > userRead[recentTopic.value];
+ });
+
+ tids = _.uniq(tids.map(topic => topic.value));
+
+ var cid = params.cid;
+ var uid = params.uid;
+ var cids;
+ var topicData;
+ var blockedUids;
+
+ tids = tids.slice(0, 200);
- function filterTopics(uid, tids, cid, filter, callback) {
if (!tids.length) {
- return callback(null, tids);
+ return callback(null, { counts: counts, tids: tids });
}
async.waterfall([
function (next) {
- privileges.topics.filterTids('read', tids, uid, next);
+ user.blocks.list(uid, next);
},
- function (tids, next) {
+ function (_blockedUids, next) {
+ blockedUids = _blockedUids;
+ filterTidsThatHaveBlockedPosts({
+ uid: uid,
+ tids: tids,
+ blockedUids: blockedUids,
+ recentTids: results.recentTids,
+ }, next);
+ },
+ function (_tids, next) {
+ tids = _tids;
+ Topics.getTopicsFields(tids, ['tid', 'cid', 'uid', 'postcount'], next);
+ },
+ function (_topicData, next) {
+ topicData = _topicData;
+ cids = _.uniq(topicData.map(topic => topic.cid)).filter(Boolean);
+
async.parallel({
- topics: function (next) {
- Topics.getTopicsFields(tids, ['tid', 'cid'], next);
- },
isTopicsFollowed: function (next) {
- if (filter === 'watched' || filter === 'new') {
- return next(null, []);
- }
db.sortedSetScores('uid:' + uid + ':followed_tids', tids, next);
},
ignoredCids: function (next) {
- if (filter === 'watched') {
- return next(null, []);
- }
user.getIgnoredCategories(uid, next);
},
+ readableCids: function (next) {
+ privileges.categories.filterCids('read', cids, uid, next);
+ },
}, next);
},
function (results, next) {
- var topics = results.topics;
cid = cid && cid.map(String);
- tids = topics.filter(function (topic, index) {
- return topic && topic.cid &&
- (!!results.isTopicsFollowed[index] || results.ignoredCids.indexOf(topic.cid.toString()) === -1) &&
- (!cid || (cid.length && cid.indexOf(String(topic.cid)) !== -1));
- }).map(function (topic) {
- return topic.tid;
+ results.readableCids = results.readableCids.map(String);
+
+ topicData.forEach(function (topic, index) {
+ function cidMatch(topicCid) {
+ return (!cid || (cid.length && cid.includes(String(topicCid)))) && results.readableCids.includes(String(topicCid));
+ }
+
+ if (topic && topic.cid && cidMatch(topic.cid) && !blockedUids.includes(parseInt(topic.uid, 10))) {
+ if ((results.isTopicsFollowed[index] || !results.ignoredCids.includes(String(topic.cid)))) {
+ counts[''] += 1;
+ tidsByFilter[''].push(topic.tid);
+ }
+
+ if (results.isTopicsFollowed[index]) {
+ counts.watched += 1;
+ tidsByFilter.watched.push(topic.tid);
+ }
+
+ if (parseInt(topic.postcount, 10) <= 1) {
+ counts.unreplied += 1;
+ tidsByFilter.unreplied.push(topic.tid);
+ }
+
+ if (!userRead[topic.tid]) {
+ counts.new += 1;
+ tidsByFilter.new.push(topic.tid);
+ }
+ }
+ });
+
+ next(null, {
+ counts: counts,
+ tids: tidsByFilter[params.filter],
+ tidsByFilter: tidsByFilter,
});
- next(null, tids);
},
], callback);
}
+ function filterTidsThatHaveBlockedPosts(params, callback) {
+ if (!params.blockedUids.length) {
+ return setImmediate(callback, null, params.tids);
+ }
+ const topicScores = _.mapValues(_.keyBy(params.recentTids, 'value'), 'score');
+
+ db.sortedSetScores('uid:' + params.uid + ':tids_read', params.tids, function (err, results) {
+ if (err) {
+ return callback(err);
+ }
+ const userScores = _.zipObject(params.tids, results);
+
+ async.filter(params.tids, function (tid, next) {
+ doesTidHaveUnblockedUnreadPosts(tid, {
+ blockedUids: params.blockedUids,
+ topicTimestamp: topicScores[tid],
+ userLastReadTimestamp: userScores[tid],
+ }, next);
+ }, callback);
+ });
+ }
+
+ function doesTidHaveUnblockedUnreadPosts(tid, params, callback) {
+ var userLastReadTimestamp = params.userLastReadTimestamp;
+ if (!userLastReadTimestamp) {
+ return setImmediate(callback, null, true);
+ }
+ var start = 0;
+ var count = 3;
+ var done = false;
+ var hasUnblockedUnread = params.topicTimestamp > userLastReadTimestamp;
+
+ async.whilst(function () {
+ return !done;
+ }, function (_next) {
+ async.waterfall([
+ function (next) {
+ db.getSortedSetRangeByScore('tid:' + tid + ':posts', start, count, userLastReadTimestamp, '+inf', next);
+ },
+ function (pidsSinceLastVisit, next) {
+ if (!pidsSinceLastVisit.length) {
+ done = true;
+ return _next();
+ }
+
+ posts.getPostsFields(pidsSinceLastVisit, ['pid', 'uid'], next);
+ },
+ function (postData, next) {
+ postData = postData.filter(function (post) {
+ return !params.blockedUids.includes(parseInt(post.uid, 10));
+ });
+
+ done = postData.length > 0;
+ hasUnblockedUnread = postData.length > 0;
+ start += count;
+ next();
+ },
+ ], _next);
+ }, function (err) {
+ callback(err, hasUnblockedUnread);
+ });
+ }
+
Topics.pushUnreadCount = function (uid, callback) {
callback = callback || function () {};
@@ -203,14 +316,15 @@ module.exports = function (Topics) {
async.waterfall([
function (next) {
- async.parallel({
- unreadTopicCount: async.apply(Topics.getTotalUnread, uid),
- unreadNewTopicCount: async.apply(Topics.getTotalUnread, uid, 'new'),
- unreadWatchedTopicCount: async.apply(Topics.getTotalUnread, uid, 'watched'),
- }, next);
+ Topics.getUnreadTids({ uid: uid, count: true }, next);
},
function (results, next) {
- require('../socket.io').in('uid_' + uid).emit('event:unread.updateCount', results);
+ require('../socket.io').in('uid_' + uid).emit('event:unread.updateCount', {
+ unreadTopicCount: results[''],
+ unreadNewTopicCount: results.new,
+ unreadWatchedTopicCount: results.watched,
+ unreadUnrepliedTopicCount: results.unreplied,
+ });
setImmediate(next);
},
], callback);
@@ -333,7 +447,7 @@ module.exports = function (Topics) {
async.waterfall([
function (next) {
async.parallel({
- recentScores: function (next) {
+ topicScores: function (next) {
db.sortedSetScores('topics:recent', tids, next);
},
userScores: function (next) {
@@ -342,17 +456,38 @@ module.exports = function (Topics) {
tids_unread: function (next) {
db.sortedSetScores('uid:' + uid + ':tids_unread', tids, next);
},
+ blockedUids: function (next) {
+ user.blocks.list(uid, next);
+ },
}, next);
},
function (results, next) {
var cutoff = Topics.unreadCutoff();
var result = tids.map(function (tid, index) {
- return !results.tids_unread[index] &&
- (results.recentScores[index] < cutoff ||
- !!(results.userScores[index] && results.userScores[index] >= results.recentScores[index]));
+ var read = !results.tids_unread[index] &&
+ (results.topicScores[index] < cutoff ||
+ !!(results.userScores[index] && results.userScores[index] >= results.topicScores[index]));
+ return { tid: tid, read: read, index: index };
});
- next(null, result);
+ async.map(result, function (data, next) {
+ if (data.read) {
+ return next(null, true);
+ }
+ doesTidHaveUnblockedUnreadPosts(data.tid, {
+ topicTimestamp: results.topicScores[data.index],
+ userLastReadTimestamp: results.userScores[data.index],
+ blockedUids: results.blockedUids,
+ }, function (err, hasUnblockedUnread) {
+ if (err) {
+ return next(err);
+ }
+ if (!hasUnblockedUnread) {
+ data.read = true;
+ }
+ next(null, data.read);
+ });
+ }, next);
},
], callback);
};
diff --git a/src/upgrades/1.10.0/hash_recent_ip_addresses.js b/src/upgrades/1.10.0/hash_recent_ip_addresses.js
index 9e13db0252..a2d7d1a99e 100644
--- a/src/upgrades/1.10.0/hash_recent_ip_addresses.js
+++ b/src/upgrades/1.10.0/hash_recent_ip_addresses.js
@@ -9,7 +9,7 @@ var nconf = require('nconf');
module.exports = {
name: 'Hash all IP addresses stored in Recent IPs zset',
- timestamp: Date.UTC(2017, 5, 22),
+ timestamp: Date.UTC(2018, 5, 22),
method: function (callback) {
const progress = this.progress;
var hashed = /[a-f0-9]{32}/;
diff --git a/src/upgrades/1.10.2/event_filters.js b/src/upgrades/1.10.2/event_filters.js
new file mode 100644
index 0000000000..4f2e87485e
--- /dev/null
+++ b/src/upgrades/1.10.2/event_filters.js
@@ -0,0 +1,46 @@
+'use strict';
+
+var db = require('../../database');
+
+var async = require('async');
+var batch = require('../../batch');
+
+module.exports = {
+ name: 'add filters to events',
+ timestamp: Date.UTC(2018, 9, 4),
+ method: function (callback) {
+ const progress = this.progress;
+
+ batch.processSortedSet('events:time', function (eids, next) {
+ async.eachSeries(eids, function (eid, next) {
+ progress.incr();
+
+ db.getObject('event:' + eid, function (err, eventData) {
+ if (err) {
+ return next(err);
+ }
+ if (!eventData) {
+ return db.sortedSetRemove('events:time', eid, next);
+ }
+ // privilege events we're missing type field
+ if (!eventData.type && eventData.privilege) {
+ eventData.type = 'privilege-change';
+ async.waterfall([
+ function (next) {
+ db.setObjectField('event:' + eid, 'type', 'privilege-change', next);
+ },
+ function (next) {
+ db.sortedSetAdd('events:time:' + eventData.type, eventData.timestamp, eid, next);
+ },
+ ], next);
+ return;
+ }
+
+ db.sortedSetAdd('events:time:' + (eventData.type || ''), eventData.timestamp, eid, next);
+ });
+ }, next);
+ }, {
+ progress: this.progress,
+ }, callback);
+ },
+};
diff --git a/src/upgrades/1.10.2/fix_category_post_zsets.js b/src/upgrades/1.10.2/fix_category_post_zsets.js
new file mode 100644
index 0000000000..216026f753
--- /dev/null
+++ b/src/upgrades/1.10.2/fix_category_post_zsets.js
@@ -0,0 +1,60 @@
+'use strict';
+
+var db = require('../../database');
+
+var async = require('async');
+var batch = require('../../batch');
+
+module.exports = {
+ name: 'Fix category post zsets',
+ timestamp: Date.UTC(2018, 9, 10),
+ method: function (callback) {
+ const progress = this.progress;
+
+ db.getSortedSetRange('categories:cid', 0, -1, function (err, cids) {
+ if (err) {
+ return callback(err);
+ }
+ var keys = cids.map(function (cid) {
+ return 'cid:' + cid + ':pids';
+ });
+ var posts = require('../../posts');
+ batch.processSortedSet('posts:pid', function (postData, next) {
+ async.eachSeries(postData, function (postData, next) {
+ progress.incr();
+ var pid = postData.value;
+ var timestamp = postData.score;
+ var cid;
+ async.waterfall([
+ function (next) {
+ posts.getCidByPid(pid, next);
+ },
+ function (_cid, next) {
+ cid = _cid;
+ db.isMemberOfSortedSets(keys, pid, next);
+ },
+ function (isMembers, next) {
+ var memberCids = [];
+ isMembers.forEach(function (isMember, index) {
+ if (isMember) {
+ memberCids.push(cids[index]);
+ }
+ });
+ if (memberCids.length > 1) {
+ async.waterfall([
+ async.apply(db.sortedSetRemove, memberCids.map(cid => 'cid:' + cid + ':pids'), pid),
+ async.apply(db.sortedSetAdd, 'cid:' + cid + ':pids', timestamp, pid),
+ ], next);
+ } else {
+ next();
+ }
+ },
+ ], next);
+ }, next);
+ }, {
+ progress: progress,
+ withScores: true,
+ }, callback);
+ });
+ },
+};
diff --git a/src/upgrades/1.10.2/fix_category_topic_zsets.js b/src/upgrades/1.10.2/fix_category_topic_zsets.js
new file mode 100644
index 0000000000..11b7b1a9da
--- /dev/null
+++ b/src/upgrades/1.10.2/fix_category_topic_zsets.js
@@ -0,0 +1,39 @@
+'use strict';
+
+var db = require('../../database');
+
+var async = require('async');
+var batch = require('../../batch');
+
+module.exports = {
+ name: 'Fix category topic zsets',
+ timestamp: Date.UTC(2018, 9, 11),
+ method: function (callback) {
+ const progress = this.progress;
+
+ var topics = require('../../topics');
+ batch.processSortedSet('topics:tid', function (tids, next) {
+ async.eachSeries(tids, function (tid, next) {
+ progress.incr();
+
+ async.waterfall([
+ function (next) {
+ db.getObjectFields('topic:' + tid, ['cid', 'pinned', 'postcount'], next);
+ },
+ function (topicData, next) {
+ if (parseInt(topicData.pinned, 10) === 1) {
+ return setImmediate(next);
+ }
+
+ db.sortedSetAdd('cid:' + topicData.cid + ':tids:posts', topicData.postcount, tid, next);
+ },
+ function (next) {
+ topics.updateLastPostTimeFromLastPid(tid, next);
+ },
+ ], next);
+ }, next);
+ }, {
+ progress: progress,
+ }, callback);
+ },
+};
diff --git a/src/upgrades/1.10.2/local_login_privileges.js b/src/upgrades/1.10.2/local_login_privileges.js
new file mode 100644
index 0000000000..6b1e5cc090
--- /dev/null
+++ b/src/upgrades/1.10.2/local_login_privileges.js
@@ -0,0 +1,17 @@
+'use strict';
+
+module.exports = {
+ name: 'Give global local login privileges',
+ timestamp: Date.UTC(2018, 8, 28),
+ method: function (callback) {
+ var meta = require('../../meta');
+ var privileges = require('../../privileges');
+ var allowLocalLogin = parseInt(meta.config.allowLocalLogin, 10) !== 0;
+
+ if (allowLocalLogin) {
+ privileges.global.give(['local:login'], 'registered-users', callback);
+ } else {
+ callback();
+ }
+ },
+};
diff --git a/src/upgrades/1.10.2/postgres_sessions.js b/src/upgrades/1.10.2/postgres_sessions.js
new file mode 100644
index 0000000000..b77a3f9b92
--- /dev/null
+++ b/src/upgrades/1.10.2/postgres_sessions.js
@@ -0,0 +1,41 @@
+'use strict';
+
+var db = require('../../database');
+var nconf = require('nconf');
+
+module.exports = {
+ name: 'Optimize PostgreSQL sessions',
+ timestamp: Date.UTC(2018, 9, 1),
+ method: function (callback) {
+ if (nconf.get('database') !== 'postgres' || nconf.get('redis')) {
+ return callback();
+ }
+
+ db.pool.query(`
+BEGIN TRANSACTION;
+
+CREATE TABLE IF NOT EXISTS "session" (
+ "sid" CHAR(32) NOT NULL
+ COLLATE "C"
+ PRIMARY KEY,
+ "sess" JSONB NOT NULL,
+ "expire" TIMESTAMPTZ NOT NULL
+) WITHOUT OIDS;
+
+CREATE INDEX IF NOT EXISTS "session_expire_idx" ON "session"("expire");
+
+ALTER TABLE "session"
+ ALTER "sid" TYPE CHAR(32) COLLATE "C",
+ ALTER "sid" SET STORAGE PLAIN,
+ ALTER "sess" TYPE JSONB,
+ ALTER "expire" TYPE TIMESTAMPTZ,
+ CLUSTER ON "session_expire_idx";
+
+CLUSTER "session";
+ANALYZE "session";
+
+COMMIT;`, function (err) {
+ callback(err);
+ });
+ },
+};
diff --git a/src/upgrades/1.10.2/upgrade_bans_to_hashes.js b/src/upgrades/1.10.2/upgrade_bans_to_hashes.js
new file mode 100644
index 0000000000..bce9b7ce43
--- /dev/null
+++ b/src/upgrades/1.10.2/upgrade_bans_to_hashes.js
@@ -0,0 +1,83 @@
+'use strict';
+
+var db = require('../../database');
+
+var async = require('async');
+var batch = require('../../batch');
+// var user = require('../../user');
+
+module.exports = {
+ name: 'Upgrade bans to hashes',
+ timestamp: Date.UTC(2018, 8, 24),
+ method: function (callback) {
+ const progress = this.progress;
+
+ batch.processSortedSet('users:joindate', function (uids, next) {
+ async.eachSeries(uids, function (uid, next) {
+ progress.incr();
+
+ async.parallel({
+ bans: function (next) {
+ db.getSortedSetRevRangeWithScores('uid:' + uid + ':bans', 0, -1, next);
+ },
+ reasons: function (next) {
+ db.getSortedSetRevRangeWithScores('banned:' + uid + ':reasons', 0, -1, next);
+ },
+ userData: function (next) {
+ db.getObjectFields('user:' + uid, ['banned', 'banned:expire', 'joindate', 'lastposttime', 'lastonline'], next);
+ },
+ }, function (err, results) {
+ function addBan(key, data, callback) {
+ async.waterfall([
+ function (next) {
+ db.setObject(key, data, next);
+ },
+ function (next) {
+ db.sortedSetAdd('uid:' + uid + ':bans:timestamp', data.timestamp, key, next);
+ },
+ ], callback);
+ }
+ if (err) {
+ return next(err);
+ }
+ // has no ban history and isn't banned, skip
+ if (!results.bans.length && !parseInt(results.userData.banned, 10)) {
+ return next();
+ }
+
+ // has no history, but is banned, create plain object with just uid and timestmap
+ if (!results.bans.length && parseInt(results.userData.banned, 10)) {
+ const banTimestamp = results.userData.lastonline || results.userData.lastposttime || results.userData.joindate || Date.now();
+ const banKey = 'uid:' + uid + ':ban:' + banTimestamp;
+ addBan(banKey, { uid: uid, timestamp: banTimestamp }, next);
+ return;
+ }
+
+ // process ban history
+ async.eachSeries(results.bans, function (ban, next) {
+ function findReason(score) {
+ return results.reasons.find(function (reasonData) {
+ return reasonData.score === score;
+ });
+ }
+ const reasonData = findReason(ban.score);
+ const banKey = 'uid:' + uid + ':ban:' + ban.score;
+ var data = {
+ uid: uid,
+ timestamp: ban.score,
+ expire: parseInt(ban.value, 10),
+ };
+ if (reasonData) {
+ data.reason = reasonData.value;
+ }
+ addBan(banKey, data, next);
+ }, function (err) {
+ next(err);
+ });
+ });
+ }, next);
+ }, {
+ progress: this.progress,
+ }, callback);
+ },
+};
diff --git a/src/upgrades/1.10.2/username_email_history.js b/src/upgrades/1.10.2/username_email_history.js
new file mode 100644
index 0000000000..1aea342b4b
--- /dev/null
+++ b/src/upgrades/1.10.2/username_email_history.js
@@ -0,0 +1,69 @@
+'use strict';
+
+var db = require('../../database');
+
+var async = require('async');
+var batch = require('../../batch');
+var user = require('../../user');
+
+module.exports = {
+ name: 'Record first entry in username/email history',
+ timestamp: Date.UTC(2018, 7, 28),
+ method: function (callback) {
+ const progress = this.progress;
+
+ batch.processSortedSet('users:joindate', function (ids, next) {
+ async.each(ids, function (uid, next) {
+ async.parallel([
+ function (next) {
+ // Username
+ async.waterfall([
+ async.apply(db.sortedSetCard, 'user:' + uid + ':usernames'),
+ (count, next) => {
+ if (count > 0) {
+ // User has changed their username before, no record of original username, skip.
+ return setImmediate(next, null, null);
+ }
+
+ user.getUserFields(uid, ['username', 'joindate'], next);
+ },
+ (userdata, next) => {
+ if (!userdata) {
+ return setImmediate(next);
+ }
+
+ db.sortedSetAdd('user:' + uid + ':usernames', userdata.joindate, [userdata.username, userdata.joindate].join(':'), next);
+ },
+ ], next);
+ },
+ function (next) {
+ // Email
+ async.waterfall([
+ async.apply(db.sortedSetCard, 'user:' + uid + ':emails'),
+ (count, next) => {
+ if (count > 0) {
+ // User has changed their email before, no record of original email, skip.
+ return setImmediate(next, null, null);
+ }
+
+ user.getUserFields(uid, ['email', 'joindate'], next);
+ },
+ (userdata, next) => {
+ if (!userdata) {
+ return setImmediate(next);
+ }
+
+ db.sortedSetAdd('user:' + uid + ':emails', userdata.joindate, [userdata.email, userdata.joindate].join(':'), next);
+ },
+ ], next);
+ },
+ ], function (err) {
+ progress.incr();
+ setImmediate(next, err);
+ });
+ }, next);
+ }, {
+ progress: this.progress,
+ }, callback);
+ },
+};
diff --git a/src/upgrades/1.4.6/delete_sessions.js b/src/upgrades/1.4.6/delete_sessions.js
index c899126bde..28db5948b5 100644
--- a/src/upgrades/1.4.6/delete_sessions.js
+++ b/src/upgrades/1.4.6/delete_sessions.js
@@ -45,10 +45,12 @@ module.exports = {
], function (err) {
next(err);
});
- } else {
+ } else if (db.client && db.client.collection) {
db.client.collection('sessions').deleteMany({}, {}, function (err) {
next(err);
});
+ } else {
+ next();
}
},
], callback);
diff --git a/src/upgrades/1.6.0/generate-email-logo.js b/src/upgrades/1.6.0/generate-email-logo.js
index 6115e773a1..5832fb9739 100644
--- a/src/upgrades/1.6.0/generate-email-logo.js
+++ b/src/upgrades/1.6.0/generate-email-logo.js
@@ -34,7 +34,6 @@ module.exports = {
image.resizeImage({
path: sourcePath,
target: uploadPath,
- extension: 'png',
height: 50,
}, next);
});
diff --git a/src/upgrades/1.7.3/key_value_schema_change.js b/src/upgrades/1.7.3/key_value_schema_change.js
index a8abefb10a..882f8ff200 100644
--- a/src/upgrades/1.7.3/key_value_schema_change.js
+++ b/src/upgrades/1.7.3/key_value_schema_change.js
@@ -23,7 +23,7 @@ module.exports = {
var cursor;
async.waterfall([
function (next) {
- client.collection('objects').count({
+ client.collection('objects').countDocuments({
_key: { $exists: true },
value: { $exists: true },
score: { $exists: false },
@@ -55,7 +55,7 @@ module.exports = {
}
delete item.expireAt;
if (Object.keys(item).length === 3 && item.hasOwnProperty('_key') && item.hasOwnProperty('value')) {
- client.collection('objects').update({ _key: item._key }, { $rename: { value: 'data' } }, next);
+ client.collection('objects').updateOne({ _key: item._key }, { $rename: { value: 'data' } }, next);
} else {
next();
}
diff --git a/src/upgrades/1.7.6/update_min_pass_strength.js b/src/upgrades/1.7.6/update_min_pass_strength.js
index c051d1d72e..e12ed5d067 100644
--- a/src/upgrades/1.7.6/update_min_pass_strength.js
+++ b/src/upgrades/1.7.6/update_min_pass_strength.js
@@ -6,7 +6,7 @@ var async = require('async');
module.exports = {
name: 'Revising minimum password strength to 1 (from 0)',
- timestamp: Date.UTC(2017, 1, 21),
+ timestamp: Date.UTC(2018, 1, 21),
method: function (callback) {
async.waterfall([
async.apply(db.getObjectField.bind(db), 'config', 'minimumPasswordStrength'),
diff --git a/src/upgrades/1.8.1/diffs_zset_to_listhash.js b/src/upgrades/1.8.1/diffs_zset_to_listhash.js
index b7a2bba296..d5e065c698 100644
--- a/src/upgrades/1.8.1/diffs_zset_to_listhash.js
+++ b/src/upgrades/1.8.1/diffs_zset_to_listhash.js
@@ -7,7 +7,7 @@ var async = require('async');
module.exports = {
name: 'Reformatting post diffs to be stored in lists and hash instead of single zset',
- timestamp: Date.UTC(2017, 2, 15),
+ timestamp: Date.UTC(2018, 2, 15),
method: function (callback) {
var progress = this.progress;
diff --git a/src/user.js b/src/user.js
index 3b005d5572..72a0d1d74c 100644
--- a/src/user.js
+++ b/src/user.js
@@ -435,3 +435,4 @@ User.addInterstitials = function (callback) {
callback();
};
+User.async = require('./promisify')(User);
diff --git a/src/user/bans.js b/src/user/bans.js
index e2cf2193b3..9868c10776 100644
--- a/src/user/bans.js
+++ b/src/user/bans.js
@@ -24,25 +24,31 @@ module.exports = function (User) {
return callback(new Error('[[error:ban-expiry-missing]]'));
}
+ const banKey = 'uid:' + uid + ':ban:' + now;
+ var banData = {
+ uid: uid,
+ timestamp: now,
+ expire: until > now ? until : 0,
+ };
+ if (reason) {
+ banData.reason = reason;
+ }
var tasks = [
async.apply(User.setUserField, uid, 'banned', 1),
async.apply(db.sortedSetAdd, 'users:banned', now, uid),
- async.apply(db.sortedSetAdd, 'uid:' + uid + ':bans', now, until),
+ async.apply(db.sortedSetAdd, 'uid:' + uid + ':bans:timestamp', now, banKey),
+ async.apply(db.setObject, banKey, banData),
];
- if (until > 0 && now < until) {
+ if (until > now) {
tasks.push(async.apply(db.sortedSetAdd, 'users:banned:expire', until, uid));
tasks.push(async.apply(User.setUserField, uid, 'banned:expire', until));
} else {
until = 0;
}
- if (reason) {
- tasks.push(async.apply(db.sortedSetAdd, 'banned:' + uid + ':reasons', now, reason));
- }
-
async.series(tasks, function (err) {
- callback(err);
+ callback(err, banData);
});
};
@@ -86,10 +92,16 @@ module.exports = function (User) {
User.getBannedReason = function (uid, callback) {
async.waterfall([
function (next) {
- db.getSortedSetRevRange('banned:' + uid + ':reasons', 0, 0, next);
+ db.getSortedSetRevRange('uid:' + uid + ':bans:timestamp', 0, 0, next);
},
- function (reasons, next) {
- next(null, reasons.length ? reasons[0] : '');
+ function (keys, next) {
+ if (!keys.length) {
+ return callback(null, '');
+ }
+ db.getObject(keys[0], next);
+ },
+ function (banObj, next) {
+ next(null, banObj && banObj.reason ? banObj.reason : '');
},
], callback);
};
diff --git a/src/user/blocks.js b/src/user/blocks.js
index a3021968e8..51bb4520f1 100644
--- a/src/user/blocks.js
+++ b/src/user/blocks.js
@@ -48,8 +48,8 @@ module.exports = function (User) {
};
User.blocks.list = function (uid, callback) {
- if (User.blocks._cache.has(uid)) {
- return setImmediate(callback, null, User.blocks._cache.get(uid));
+ if (User.blocks._cache.has(parseInt(uid, 10))) {
+ return setImmediate(callback, null, User.blocks._cache.get(parseInt(uid, 10)));
}
db.getSortedSetRange('uid:' + uid + ':blocked_uids', 0, -1, function (err, blocked) {
@@ -73,8 +73,8 @@ module.exports = function (User) {
async.apply(db.sortedSetAdd.bind(db), 'uid:' + uid + ':blocked_uids', Date.now(), targetUid),
async.apply(User.incrementUserFieldBy, uid, 'blocksCount', 1),
function (_blank, next) {
- User.blocks._cache.del(uid);
- pubsub.publish('user:blocks:cache:del', uid);
+ User.blocks._cache.del(parseInt(uid, 10));
+ pubsub.publish('user:blocks:cache:del', parseInt(uid, 10));
setImmediate(next);
},
], callback);
@@ -86,8 +86,8 @@ module.exports = function (User) {
async.apply(db.sortedSetRemove.bind(db), 'uid:' + uid + ':blocked_uids', targetUid),
async.apply(User.decrementUserFieldBy, uid, 'blocksCount', 1),
function (_blank, next) {
- User.blocks._cache.del(uid);
- pubsub.publish('user:blocks:cache:del', uid);
+ User.blocks._cache.del(parseInt(uid, 10));
+ pubsub.publish('user:blocks:cache:del', parseInt(uid, 10));
setImmediate(next);
},
], callback);
diff --git a/src/user/create.js b/src/user/create.js
index b0eba41c6b..a82c050409 100644
--- a/src/user/create.js
+++ b/src/user/create.js
@@ -93,6 +93,9 @@ module.exports = function (User) {
function (next) {
db.sortedSetsAdd(['users:postcount', 'users:reputation'], 0, userData.uid, next);
},
+ function (next) {
+ db.sortedSetAdd('user:' + userData.uid + ':usernames', timestamp, userData.username, next);
+ },
function (next) {
groups.join('registered-users', userData.uid, next);
},
@@ -104,6 +107,7 @@ module.exports = function (User) {
async.parallel([
async.apply(db.sortedSetAdd, 'email:uid', userData.uid, userData.email.toLowerCase()),
async.apply(db.sortedSetAdd, 'email:sorted', 0, userData.email.toLowerCase() + ':' + userData.uid),
+ async.apply(db.sortedSetAdd, 'user:' + userData.uid + ':emails', timestamp, userData.email),
], next);
if (parseInt(userData.uid, 10) !== 1 && parseInt(meta.config.requireEmailConfirmation, 10) === 1) {
diff --git a/src/user/data.js b/src/user/data.js
index 05ebc2f9b2..dc851dc8c4 100644
--- a/src/user/data.js
+++ b/src/user/data.js
@@ -119,6 +119,11 @@ module.exports = function (User) {
return memo;
}, {});
var users = uids.map(function (uid) {
+ const returnPayload = usersData[ref[uid]];
+ if (uid > 0 && !returnPayload.uid) {
+ returnPayload.oldUid = parseInt(uid, 10);
+ }
+
return usersData[ref[uid]];
});
return users;
@@ -144,7 +149,7 @@ module.exports = function (User) {
if (!parseInt(user.uid, 10)) {
user.uid = 0;
- user.username = '[[global:guest]]';
+ user.username = (user.hasOwnProperty('oldUid') && parseInt(user.oldUid, 10)) ? '[[global:former_user]]' : '[[global:guest]]';
user.userslug = '';
user.picture = User.getDefaultAvatar();
user['icon:text'] = '?';
diff --git a/src/user/delete.js b/src/user/delete.js
index 6712569634..f84ee25393 100644
--- a/src/user/delete.js
+++ b/src/user/delete.js
@@ -15,12 +15,20 @@ var batch = require('../batch');
var file = require('../file');
module.exports = function (User) {
+ var deletesInProgress = {};
+
User.delete = function (callerUid, uid, callback) {
if (!parseInt(uid, 10)) {
- return callback(new Error('[[error:invalid-uid]]'));
+ return setImmediate(callback, new Error('[[error:invalid-uid]]'));
}
-
+ if (deletesInProgress[uid]) {
+ return setImmediate(callback, new Error('[[error:already-deleting]]'));
+ }
+ deletesInProgress[uid] = 'user.delete';
async.waterfall([
+ function (next) {
+ removeFromSortedSets(uid, next);
+ },
function (next) {
deletePosts(callerUid, uid, next);
},
@@ -67,15 +75,39 @@ module.exports = function (User) {
}, { alwaysStartAt: 0 }, callback);
}
+ function removeFromSortedSets(uid, callback) {
+ db.sortedSetsRemove([
+ 'users:joindate',
+ 'users:postcount',
+ 'users:reputation',
+ 'users:banned',
+ 'users:banned:expire',
+ 'users:flags',
+ 'users:online',
+ 'users:notvalidated',
+ 'digest:day:uids',
+ 'digest:week:uids',
+ 'digest:month:uids',
+ ], uid, callback);
+ }
+
User.deleteAccount = function (uid, callback) {
+ if (deletesInProgress[uid] === 'user.deleteAccount') {
+ return setImmediate(callback, new Error('[[error:already-deleting]]'));
+ }
+ deletesInProgress[uid] = 'user.deleteAccount';
var userData;
async.waterfall([
+ function (next) {
+ removeFromSortedSets(uid, next);
+ },
function (next) {
db.getObject('user:' + uid, next);
},
function (_userData, next) {
if (!_userData || !_userData.username) {
- return callback();
+ delete deletesInProgress[uid];
+ return callback(new Error('[[error:no-user]]'));
}
userData = _userData;
plugins.fireHook('static:user.delete', { uid: uid }, next);
@@ -113,19 +145,6 @@ module.exports = function (User) {
next();
}
},
- function (next) {
- db.sortedSetsRemove([
- 'users:joindate',
- 'users:postcount',
- 'users:reputation',
- 'users:banned',
- 'users:online',
- 'users:notvalidated',
- 'digest:day:uids',
- 'digest:week:uids',
- 'digest:month:uids',
- ], uid, next);
- },
function (next) {
db.decrObjectField('global', 'userCount', next);
},
@@ -149,6 +168,9 @@ module.exports = function (User) {
function (next) {
deleteUserIps(uid, next);
},
+ function (next) {
+ deleteBans(uid, next);
+ },
function (next) {
deleteUserFromFollowers(uid, next);
},
@@ -160,7 +182,10 @@ module.exports = function (User) {
function (results, next) {
db.deleteAll(['followers:' + uid, 'following:' + uid, 'user:' + uid], next);
},
- ], callback);
+ ], function (err) {
+ delete deletesInProgress[uid];
+ callback(err, userData);
+ });
};
function deleteVotes(uid, callback) {
@@ -220,6 +245,20 @@ module.exports = function (User) {
], callback);
}
+ function deleteBans(uid, callback) {
+ async.waterfall([
+ function (next) {
+ db.getSortedSetRange('uid:' + uid + ':bans:timestamp', 0, -1, next);
+ },
+ function (bans, next) {
+ db.deleteAll(bans, next);
+ },
+ function (next) {
+ db.delete('uid:' + uid + ':bans:timestamp', next);
+ },
+ ], callback);
+ }
+
function deleteUserFromFollowers(uid, callback) {
async.parallel({
followers: async.apply(db.getSortedSetRange, 'followers:' + uid, 0, -1),
diff --git a/src/user/email.js b/src/user/email.js
index 598536ad29..24559104a3 100644
--- a/src/user/email.js
+++ b/src/user/email.js
@@ -100,14 +100,16 @@ UserEmail.sendValidationEmail = function (uid, options, callback) {
},
function (username, next) {
var title = meta.config.title || meta.config.browserTitle || 'NodeBB';
- translator.translate('[[email:welcome-to, ' + title + ']]', meta.config.defaultLang, function (subject) {
+ var subject = options.subject || '[[email:welcome-to, ' + title + ']]';
+ var template = options.template || 'welcome';
+ translator.translate(subject, meta.config.defaultLang, function (subject) {
var data = {
username: username,
confirm_link: confirm_link,
confirm_code: confirm_code,
subject: subject,
- template: 'welcome',
+ template: template,
uid: uid,
};
@@ -115,7 +117,7 @@ UserEmail.sendValidationEmail = function (uid, options, callback) {
plugins.fireHook('action:user.verify', { uid: uid, data: data });
next();
} else {
- emailer.send('welcome', uid, data, next);
+ emailer.send(template, uid, data, next);
}
});
},
diff --git a/src/user/info.js b/src/user/info.js
index 5e91c6cf08..aabdac3534 100644
--- a/src/user/info.js
+++ b/src/user/info.js
@@ -5,6 +5,7 @@ var _ = require('lodash');
var validator = require('validator');
var db = require('../database');
+var user = require('../user');
var posts = require('../posts');
var topics = require('../topics');
var utils = require('../../public/src/utils');
@@ -12,30 +13,25 @@ var utils = require('../../public/src/utils');
module.exports = function (User) {
User.getLatestBanInfo = function (uid, callback) {
// Simply retrieves the last record of the user's ban, even if they've been unbanned since then.
- var timestamp;
- var expiry;
- var reason;
-
async.waterfall([
- async.apply(db.getSortedSetRevRangeWithScores, 'uid:' + uid + ':bans', 0, 0),
+ function (next) {
+ db.getSortedSetRevRange('uid:' + uid + ':bans:timestamp', 0, 0, next);
+ },
function (record, next) {
if (!record.length) {
return next(new Error('no-ban-info'));
}
-
- timestamp = record[0].score;
- expiry = record[0].value;
-
- db.getSortedSetRangeByScore('banned:' + uid + ':reasons', 0, -1, timestamp, timestamp, next);
+ db.getObject(record[0], next);
},
- function (_reason, next) {
- reason = _reason && _reason.length ? _reason[0] : '';
+ function (banInfo, next) {
+ var expiry = banInfo.expire;
+
next(null, {
uid: uid,
- timestamp: timestamp,
+ timestamp: banInfo.timestamp,
expiry: parseInt(expiry, 10),
expiry_readable: new Date(parseInt(expiry, 10)).toString(),
- reason: validator.escape(String(reason)),
+ reason: validator.escape(String(banInfo.reason || '')),
});
},
], callback);
@@ -46,8 +42,7 @@ module.exports = function (User) {
function (next) {
async.parallel({
flags: async.apply(db.getSortedSetRevRangeWithScores, 'flags:byTargetUid:' + uid, 0, 19),
- bans: async.apply(db.getSortedSetRevRangeWithScores, 'uid:' + uid + ':bans', 0, 19),
- reasons: async.apply(db.getSortedSetRevRangeWithScores, 'banned:' + uid + ':reasons', 0, 19),
+ bans: async.apply(db.getSortedSetRevRange, 'uid:' + uid + ':bans:timestamp', 0, 19),
}, next);
},
function (data, next) {
@@ -76,8 +71,7 @@ module.exports = function (User) {
});
},
function (data, next) {
- formatBanData(data);
- next(null, data);
+ formatBanData(data, next);
},
], callback);
};
@@ -131,26 +125,31 @@ module.exports = function (User) {
], callback);
}
- function formatBanData(data) {
- var reasons = data.reasons.reduce(function (memo, cur) {
- memo[cur.score] = cur.value;
- return memo;
- }, {});
+ function formatBanData(data, callback) {
+ var banData;
+ async.waterfall([
+ function (next) {
+ db.getObjects(data.bans, next);
+ },
+ function (_banData, next) {
+ banData = _banData;
+ var uids = banData.map(banData => banData.fromUid);
- data.bans = data.bans.map(function (banObj) {
- banObj.until = parseInt(banObj.value, 10);
- banObj.untilReadable = new Date(banObj.until).toString();
- banObj.timestamp = parseInt(banObj.score, 10);
- banObj.timestampReadable = new Date(banObj.score).toString();
- banObj.timestampISO = new Date(banObj.score).toISOString();
- banObj.reason = validator.escape(String(reasons[banObj.score] || '')) || '[[user:info.banned-no-reason]]';
-
- delete banObj.value;
- delete banObj.score;
- delete data.reasons;
-
- return banObj;
- });
+ user.getUsersFields(uids, ['uid', 'username', 'userslug', 'picture'], next);
+ },
+ function (usersData, next) {
+ data.bans = banData.map(function (banObj, index) {
+ banObj.user = usersData[index];
+ banObj.until = parseInt(banObj.expire, 10);
+ banObj.untilReadable = new Date(banObj.until).toString();
+ banObj.timestampReadable = new Date(banObj.timestamp).toString();
+ banObj.timestampISO = utils.toISOString(banObj.timestamp);
+ banObj.reason = validator.escape(String(banObj.reason || '')) || '[[user:info.banned-no-reason]]';
+ return banObj;
+ });
+ next(null, data);
+ },
+ ], callback);
}
User.getModerationNotes = function (uid, start, stop, callback) {
diff --git a/src/user/password.js b/src/user/password.js
index d644fd2e8f..bfb65f9cb3 100644
--- a/src/user/password.js
+++ b/src/user/password.js
@@ -15,7 +15,7 @@ module.exports = function (User) {
Password.hash(nconf.get('bcrypt_rounds') || 12, password, callback);
};
- User.isPasswordCorrect = function (uid, password, callback) {
+ User.isPasswordCorrect = function (uid, password, ip, callback) {
password = password || '';
var hashedPassword;
async.waterfall([
@@ -25,14 +25,22 @@ module.exports = function (User) {
function (_hashedPassword, next) {
hashedPassword = _hashedPassword;
if (!hashedPassword) {
- return callback(null, true);
+ // Non-existant user, submit fake hash for comparison
+ hashedPassword = '';
}
User.isPasswordValid(password, 0, next);
},
+ async.apply(User.auth.logAttempt, uid, ip),
function (next) {
Password.compare(password, hashedPassword, next);
},
+ function (ok, next) {
+ if (ok) {
+ User.auth.clearLoginAttempts(uid);
+ }
+ next(null, ok);
+ },
], callback);
};
diff --git a/src/user/picture.js b/src/user/picture.js
index d1a4dac7b0..d39bce659d 100644
--- a/src/user/picture.js
+++ b/src/user/picture.js
@@ -37,7 +37,7 @@ module.exports = function (User) {
async.waterfall([
function (next) {
- var size = data.file ? data.file.size : data.imageData.length;
+ var size = data.file ? data.file.size : image.sizeFromBase64(data.imageData);
meta.config.maximumCoverImageSize = meta.config.maximumCoverImageSize || 2048;
if (size > parseInt(meta.config.maximumCoverImageSize, 10) * 1024) {
return next(new Error('[[error:file-too-big, ' + meta.config.maximumCoverImageSize + ']]'));
@@ -89,10 +89,10 @@ module.exports = function (User) {
return callback(new Error('[[error:invalid-data]]'));
}
- var size = data.file ? data.file.size : data.imageData.length;
+ var size = data.file ? data.file.size : image.sizeFromBase64(data.imageData);
var uploadSize = parseInt(meta.config.maximumProfileImageSize, 10) || 256;
if (size > uploadSize * 1024) {
- return callback(new Error('[[error:file-too-big, ' + meta.config.maximumProfileImageSize + ']]'));
+ return callback(new Error('[[error:file-too-big, ' + uploadSize + ']]'));
}
var type = data.file ? data.file.type : image.mimeFromBase64(data.imageData);
@@ -123,11 +123,9 @@ module.exports = function (User) {
},
function (path, next) {
picture.path = path;
-
var imageDimension = parseInt(meta.config.profileImageDimension, 10) || 200;
image.resizeImage({
path: picture.path,
- extension: extension,
width: imageDimension,
height: imageDimension,
}, next);
diff --git a/src/user/profile.js b/src/user/profile.js
index 5da6eb7409..6f2d3252b5 100644
--- a/src/user/profile.js
+++ b/src/user/profile.js
@@ -45,8 +45,6 @@ module.exports = function (User) {
return updateUsername(updateUid, data.username, next);
} else if (field === 'fullname') {
return updateFullname(updateUid, data.fullname, next);
- } else if (field === 'signature') {
- data[field] = utils.stripHTMLTags(data[field]);
}
User.setUserField(updateUid, field, data[field], next);
@@ -170,7 +168,7 @@ module.exports = function (User) {
User.checkMinReputation = function (callerUid, uid, setting, callback) {
var isSelf = parseInt(callerUid, 10) === parseInt(uid, 10);
- if (!isSelf) {
+ if (!isSelf || parseInt(meta.config['reputation:disabled'], 10) === 1) {
return setImmediate(callback);
}
async.waterfall([
@@ -223,6 +221,8 @@ module.exports = function (User) {
if (parseInt(meta.config.requireEmailConfirmation, 10) === 1 && newEmail) {
User.email.sendValidationEmail(uid, {
email: newEmail,
+ subject: '[[email:email.verify-your-email.subject]]',
+ template: 'verify_email',
});
}
User.setUserField(uid, 'email:confirmed', 0, next);
@@ -322,12 +322,12 @@ module.exports = function (User) {
if (parseInt(uid, 10) !== parseInt(data.uid, 10)) {
User.isAdministrator(uid, next);
} else {
- User.isPasswordCorrect(uid, data.currentPassword, next);
+ User.isPasswordCorrect(uid, data.currentPassword, data.ip, next);
}
},
function (isAdminOrPasswordMatch, next) {
if (!isAdminOrPasswordMatch) {
- return next(new Error('[[error:change_password_error_wrong_current]]'));
+ return next(new Error('[[user:change_password_error_wrong_current]]'));
}
User.hashPassword(data.newPassword, next);
diff --git a/src/views/admin/advanced/database.tpl b/src/views/admin/advanced/database.tpl
index 9519ce9141..53533a9249 100644
--- a/src/views/admin/advanced/database.tpl
+++ b/src/views/admin/advanced/database.tpl
@@ -57,6 +57,19 @@
+
+
+
[[admin/advanced/database:postgres]]
+
+
+ [[admin/advanced/database:postgres.version]] {postgres.version}
+
+ [[admin/advanced/database:uptime-seconds]] {postgres.uptime}
+
+
+
+
+
+
+
+
+
+
[[admin/advanced/database:postgres.raw-info]]
+
+
+
+
+
diff --git a/src/views/admin/advanced/events.tpl b/src/views/admin/advanced/events.tpl
index 65c707920f..4372d12b93 100644
--- a/src/views/admin/advanced/events.tpl
+++ b/src/views/admin/advanced/events.tpl
@@ -1,8 +1,13 @@
+
+
+ selected>{filters.name}
+
+
[[admin/advanced/events:events]]
-
+
[[admin/advanced/events:no-events]]
diff --git a/src/views/admin/extend/widgets.tpl b/src/views/admin/extend/widgets.tpl
index 9d02b2fb81..3d984516c1 100644
--- a/src/views/admin/extend/widgets.tpl
+++ b/src/views/admin/extend/widgets.tpl
@@ -85,18 +85,18 @@
[[admin/extend/widgets:containers.none]]
-
+
[[admin/extend/widgets:container.well]]
-
+
[[admin/extend/widgets:container.jumbotron]]
-
+
[[admin/extend/widgets:container.panel]]
-
+
[[admin/extend/widgets:container.panel-header]]
@@ -113,7 +113,7 @@
-
+
[[admin/extend/widgets:container.alert]]
diff --git a/src/views/admin/partials/categories/create.tpl b/src/views/admin/partials/categories/create.tpl
index 436705b1d8..c1daa78925 100644
--- a/src/views/admin/partials/categories/create.tpl
+++ b/src/views/admin/partials/categories/create.tpl
@@ -21,5 +21,17 @@
{categories.name}
+
+
+ [[admin/manage/categories:clone-children]]
+
+
+
+
+
+ [[admin/manage/categories:disable-on-create]]
+
+
+
\ No newline at end of file
diff --git a/src/views/admin/settings/advanced.tpl b/src/views/admin/settings/advanced.tpl
index 44d34fa80f..4aec0e51fa 100644
--- a/src/views/admin/settings/advanced.tpl
+++ b/src/views/admin/settings/advanced.tpl
@@ -67,6 +67,12 @@
diff --git a/src/views/admin/settings/uploads.tpl b/src/views/admin/settings/uploads.tpl
index e523c928c5..d008b0d9d3 100644
--- a/src/views/admin/settings/uploads.tpl
+++ b/src/views/admin/settings/uploads.tpl
@@ -20,6 +20,14 @@
+
+
[[admin/settings/uploads:max-image-width]]
@@ -44,6 +52,22 @@
+
+
+
+
diff --git a/src/views/admin/settings/user.tpl b/src/views/admin/settings/user.tpl
index 5b1a003068..415c516477 100644
--- a/src/views/admin/settings/user.tpl
+++ b/src/views/admin/settings/user.tpl
@@ -4,13 +4,6 @@
-
-
-
- [[admin/settings/user:allow-local-login]]
-
-
-
There was an error connecting to your database. Please try again.
diff --git a/src/webserver.js b/src/webserver.js
index be4c02f443..f83dfcb26e 100644
--- a/src/webserver.js
+++ b/src/webserver.js
@@ -108,7 +108,7 @@ function initializeNodeBB(callback) {
var middleware = require('./middleware');
async.waterfall([
- async.apply(meta.themes.setupPaths),
+ meta.themes.setupPaths,
function (next) {
plugins.init(app, middleware, next);
},
@@ -125,13 +125,9 @@ function initializeNodeBB(callback) {
function (hotswapIds, next) {
routes(app, middleware, hotswapIds, next);
},
- function (next) {
- async.series([
- meta.sounds.addUploads,
- meta.blacklist.load,
- flags.init,
- ], next);
- },
+ meta.sounds.addUploads,
+ meta.blacklist.load,
+ flags.init,
], function (err) {
callback(err);
});
@@ -147,15 +143,7 @@ function setupExpressApp(app, callback) {
app.engine('tpl', function (filepath, data, next) {
filepath = filepath.replace(/\.tpl$/, '.js');
- middleware.templatesOnDemand({
- filePath: filepath,
- }, null, function (err) {
- if (err) {
- return next(err);
- }
-
- Benchpress.__express(filepath, data, next);
- });
+ Benchpress.__express(filepath, data, next);
});
app.set('view engine', 'tpl');
app.set('views', viewsDir);
@@ -193,13 +181,19 @@ function setupExpressApp(app, callback) {
saveUninitialized: true,
}));
- app.use(helmet());
- app.use(helmet.referrerPolicy({ policy: 'strict-origin-when-cross-origin' }));
- app.use(helmet.hsts({
+ var hsts_option = {
maxAge: parseInt(meta.config['hsts-maxage'], 10) || 31536000,
includeSubdomains: !!parseInt(meta.config['hsts-subdomains'], 10),
preload: !!parseInt(meta.config['hsts-preload'], 10),
+ setIf: function () {
+ // If not set, default to on - previous and recommended behavior
+ return meta.config['hsts-enabled'] === undefined || !!parseInt(meta.config['hsts-enabled'], 10);
+ },
+ };
+ app.use(helmet({
+ hsts: hsts_option,
}));
+ app.use(helmet.referrerPolicy({ policy: 'strict-origin-when-cross-origin' }));
app.use(middleware.addHeaders);
app.use(middleware.processRender);
auth.initialize(app, middleware);
diff --git a/src/widgets/index.js b/src/widgets/index.js
index d950429ce5..d2440d20db 100644
--- a/src/widgets/index.js
+++ b/src/widgets/index.js
@@ -51,7 +51,9 @@ widgets.render = function (uid, options, callback) {
if (err) {
return done(err);
}
- returnData[location] = renderedWidgets.filter(Boolean);
+ renderedWidgets = renderedWidgets.filter(Boolean);
+ returnData[location] = renderedWidgets.length ? renderedWidgets : undefined;
+
done();
});
}, function (err) {
diff --git a/test/authentication.js b/test/authentication.js
index b90d585971..f22ae7fc04 100644
--- a/test/authentication.js
+++ b/test/authentication.js
@@ -9,6 +9,7 @@ var async = require('async');
var db = require('./mocks/databasemock');
var user = require('../src/user');
var meta = require('../src/meta');
+var privileges = require('../src/privileges');
var helpers = require('./helpers');
describe('authentication', function () {
@@ -303,6 +304,18 @@ describe('authentication', function () {
});
});
+ it('should fail to login if user does not have password field in db', function (done) {
+ user.create({ username: 'hasnopassword', email: 'no@pass.org' }, function (err, uid) {
+ assert.ifError(err);
+ loginUser('hasnopassword', 'doesntmatter', function (err, response, body) {
+ assert.ifError(err);
+ assert.equal(response.statusCode, 403);
+ assert.equal(body, '[[error:invalid-login-credentials]]');
+ done();
+ });
+ });
+ });
+
it('should fail to login if password is longer than 4096', function (done) {
var longPassword;
for (var i = 0; i < 5000; i++) {
@@ -316,15 +329,15 @@ describe('authentication', function () {
});
});
-
it('should fail to login if local login is disabled', function (done) {
- meta.config.allowLocalLogin = 0;
- loginUser('someuser', 'somepass', function (err, response, body) {
- meta.config.allowLocalLogin = 1;
+ privileges.global.rescind(['local:login'], 'registered-users', function (err) {
assert.ifError(err);
- assert.equal(response.statusCode, 403);
- assert.equal(body, '[[error:local-login-disabled]]');
- done();
+ loginUser('regular', 'regularpwd', function (err, response, body) {
+ assert.ifError(err);
+ assert.equal(response.statusCode, 403);
+ assert.equal(body, '[[error:local-login-disabled]]');
+ privileges.global.give(['local:login'], 'registered-users', done);
+ });
});
});
diff --git a/test/blacklist.js b/test/blacklist.js
index 9ec1e557d3..d6ccb23260 100644
--- a/test/blacklist.js
+++ b/test/blacklist.js
@@ -15,7 +15,6 @@ describe('blacklist', function () {
var adminUid;
before(function (done) {
- groups.resetCache();
user.create({ username: 'admin' }, function (err, uid) {
assert.ifError(err);
adminUid = uid;
diff --git a/test/build.js b/test/build.js
index 35471d0fd6..20888f0b0c 100644
--- a/test/build.js
+++ b/test/build.js
@@ -188,6 +188,7 @@ describe('Build', function (done) {
});
it('should build templates', function (done) {
+ this.timeout(0);
build.build(['templates'], function (err) {
assert.ifError(err);
var filename = path.join(__dirname, '../build/public/templates/admin/header.tpl');
diff --git a/test/categories.js b/test/categories.js
index ce3f3f0b8d..04723eb28f 100644
--- a/test/categories.js
+++ b/test/categories.js
@@ -19,7 +19,6 @@ describe('Categories', function () {
var adminUid;
before(function (done) {
- groups.resetCache();
async.series({
posterUid: function (next) {
User.create({ username: 'poster' }, next);
@@ -675,6 +674,7 @@ describe('Categories', function () {
'upload:post:image': false,
'upload:post:file': false,
signature: false,
+ 'local:login': false,
});
done();
@@ -718,6 +718,7 @@ describe('Categories', function () {
'groups:upload:post:image': true,
'groups:upload:post:file': false,
'groups:signature': true,
+ 'groups:local:login': true,
});
done();
diff --git a/test/controllers-admin.js b/test/controllers-admin.js
index 24da882cbe..d0bec4e74c 100644
--- a/test/controllers-admin.js
+++ b/test/controllers-admin.js
@@ -23,7 +23,6 @@ describe('Admin Controllers', function () {
var jar;
before(function (done) {
- groups.resetCache();
async.series({
category: function (next) {
categories.create({
@@ -153,7 +152,6 @@ describe('Admin Controllers', function () {
assert(body.history);
assert(Array.isArray(body.history.flags));
assert(Array.isArray(body.history.bans));
- assert(Array.isArray(body.history.reasons));
assert(Array.isArray(body.sessions));
done();
});
@@ -185,6 +183,8 @@ describe('Admin Controllers', function () {
assert(body.redis);
} else if (nconf.get('mongo')) {
assert(body.mongo);
+ } else if (nconf.get('postgres')) {
+ assert(body.postgres);
}
done();
});
diff --git a/test/controllers.js b/test/controllers.js
index 2104fc3230..e26694ba4d 100644
--- a/test/controllers.js
+++ b/test/controllers.js
@@ -28,7 +28,6 @@ describe('Controllers', function () {
var category;
before(function (done) {
- groups.resetCache();
async.series({
category: function (next) {
categories.create({
@@ -72,15 +71,17 @@ describe('Controllers', function () {
});
}
var message = utils.generateUUID();
- var tplPath = path.join(nconf.get('views_dir'), 'custom.tpl');
+ var name = 'custom.tpl';
+ var tplPath = path.join(nconf.get('views_dir'), name);
- before(function () {
+ before(function (done) {
plugins.registerHook('myTestPlugin', {
hook: 'action:homepage.get:custom',
method: hookMethod,
});
fs.writeFileSync(tplPath, message);
+ meta.templates.compileTemplate(name, message, done);
});
it('should load default', function (done) {
@@ -440,6 +441,15 @@ describe('Controllers', function () {
});
});
+ it('should load topics rss feed', function (done) {
+ request(nconf.get('url') + '/topics.rss', function (err, res, body) {
+ assert.ifError(err);
+ assert.equal(res.statusCode, 200);
+ assert(body);
+ done();
+ });
+ });
+
it('should load recent rss feed', function (done) {
request(nconf.get('url') + '/recent.rss', function (err, res, body) {
assert.ifError(err);
@@ -1064,7 +1074,6 @@ describe('Controllers', function () {
});
});
-
it('should load /user/foo/posts', function (done) {
request(nconf.get('url') + '/api/user/foo/posts', function (err, res, body) {
assert.ifError(err);
@@ -1164,6 +1173,24 @@ describe('Controllers', function () {
});
});
+ it('should load /user/foo/sessions', function (done) {
+ request(nconf.get('url') + '/api/user/foo/sessions', { jar: jar }, function (err, res, body) {
+ assert.ifError(err);
+ assert.equal(res.statusCode, 200);
+ assert(body);
+ done();
+ });
+ });
+
+ it('should load /user/foo/categories', function (done) {
+ request(nconf.get('url') + '/api/user/foo/categories', { jar: jar }, function (err, res, body) {
+ assert.ifError(err);
+ assert.equal(res.statusCode, 200);
+ assert(body);
+ done();
+ });
+ });
+
it('should load /user/foo/uploads', function (done) {
request(nconf.get('url') + '/api/user/foo/uploads', { jar: jar }, function (err, res, body) {
assert.ifError(err);
diff --git a/test/database.js b/test/database.js
index 327d8095cf..b88e425fd6 100644
--- a/test/database.js
+++ b/test/database.js
@@ -48,6 +48,11 @@ describe('Test database', function () {
assert.equal(err.message, 'The `mongodb` package is out-of-date, please run `./nodebb setup` again.');
done();
});
+ } else if (dbName === 'postgres') {
+ db.checkCompatibilityVersion('6.3.0', function (err) {
+ assert.equal(err.message, 'The `pg` package is out-of-date, please run `./nodebb setup` again.');
+ done();
+ });
}
});
});
diff --git a/test/database/keys.js b/test/database/keys.js
index 6b187b4e3d..1051658b26 100644
--- a/test/database/keys.js
+++ b/test/database/keys.js
@@ -192,6 +192,24 @@ describe('Key methods', function () {
});
});
});
+
+ it('should rename multiple keys', function (done) {
+ db.sortedSetAdd('zsettorename', [1, 2, 3], ['value1', 'value2', 'value3'], function (err) {
+ assert.ifError(err);
+ db.rename('zsettorename', 'newzsetname', function (err) {
+ assert.ifError(err);
+ db.exists('zsettorename', function (err, exists) {
+ assert.ifError(err);
+ assert(!exists);
+ db.getSortedSetRange('newzsetname', 0, -1, function (err, values) {
+ assert.ifError(err);
+ assert.deepEqual(['value1', 'value2', 'value3'], values);
+ done();
+ });
+ });
+ });
+ });
+ });
});
describe('type', function () {
diff --git a/test/database/list.js b/test/database/list.js
index 23768eb85d..76e7029f5c 100644
--- a/test/database/list.js
+++ b/test/database/list.js
@@ -111,16 +111,16 @@ describe('List methods', function () {
before(function (done) {
async.series([
function (next) {
- db.listAppend('testList4', 12, next);
+ db.listAppend('testList7', 12, next);
},
function (next) {
- db.listPrepend('testList4', 9, next);
+ db.listPrepend('testList7', 9, next);
},
], done);
});
it('should remove the last element of list and return it', function (done) {
- db.listRemoveLast('testList4', function (err, lastElement) {
+ db.listRemoveLast('testList7', function (err, lastElement) {
assert.equal(err, null);
assert.equal(arguments.length, 2);
assert.equal(lastElement, '12');
diff --git a/test/database/sorted.js b/test/database/sorted.js
index 2b7babf951..ab6ba95652 100644
--- a/test/database/sorted.js
+++ b/test/database/sorted.js
@@ -42,6 +42,21 @@ describe('Sorted Set methods', function () {
done();
});
});
+
+ it('should gracefully handle adding the same element twice', function (done) {
+ db.sortedSetAdd('sorted2', [1, 2], ['value1', 'value1'], function (err) {
+ assert.equal(err, null);
+ assert.equal(arguments.length, 1);
+
+ db.sortedSetScore('sorted2', 'value1', function (err, score) {
+ assert.equal(err, null);
+ assert.equal(score, 2);
+ assert.equal(arguments.length, 2);
+
+ done();
+ });
+ });
+ });
});
describe('sortedSetsAdd()', function () {
@@ -693,9 +708,9 @@ describe('Sorted Set methods', function () {
assert.ifError(err);
db.sortedSetAdd('multiTest6', [2], ['two'], function (err) {
assert.ifError(err);
- db.sortedSetAdd('multiTest7', [3], ['three'], function (err) {
+ db.sortedSetAdd('multiTest7', [3], [333], function (err) {
assert.ifError(err);
- db.sortedSetRemove(['multiTest5', 'multiTest6', 'multiTest7'], ['one', 'two', 'three'], function (err) {
+ db.sortedSetRemove(['multiTest5', 'multiTest6', 'multiTest7'], ['one', 'two', 333], function (err) {
assert.ifError(err);
db.getSortedSetsMembers(['multiTest5', 'multiTest6', 'multiTest7'], function (err, members) {
assert.ifError(err);
diff --git a/test/feeds.js b/test/feeds.js
index 2d8156404c..cbc059e580 100644
--- a/test/feeds.js
+++ b/test/feeds.js
@@ -20,7 +20,6 @@ describe('feeds', function () {
var fooUid;
var cid;
before(function (done) {
- groups.resetCache();
meta.config['feeds:disableRSS'] = 1;
async.series({
category: function (next) {
@@ -52,6 +51,7 @@ describe('feeds', function () {
var feedUrls = [
nconf.get('url') + '/topic/' + tid + '.rss',
nconf.get('url') + '/category/' + cid + '.rss',
+ nconf.get('url') + '/topics.rss',
nconf.get('url') + '/recent.rss',
nconf.get('url') + '/top.rss',
nconf.get('url') + '/popular.rss',
@@ -82,6 +82,14 @@ describe('feeds', function () {
});
});
+ it('should 404 if category id is not a number', function (done) {
+ request(nconf.get('url') + '/category/invalid.rss', function (err, res) {
+ assert.ifError(err);
+ assert.equal(res.statusCode, 404);
+ done();
+ });
+ });
+
it('should redirect if we do not have read privilege', function (done) {
privileges.categories.rescind(['topics:read'], cid, 'guests', function (err) {
assert.ifError(err);
diff --git a/test/files/brokenimage.png b/test/files/brokenimage.png
new file mode 100644
index 0000000000..74c4de9f2b
Binary files /dev/null and b/test/files/brokenimage.png differ
diff --git a/test/files/notanimage.png b/test/files/notanimage.png
new file mode 100644
index 0000000000..a15bbaf805
--- /dev/null
+++ b/test/files/notanimage.png
@@ -0,0 +1 @@
+this is totally not a png
\ No newline at end of file
diff --git a/test/files/toobig.jpg b/test/files/toobig.jpg
new file mode 100644
index 0000000000..e25c2d3015
Binary files /dev/null and b/test/files/toobig.jpg differ
diff --git a/test/flags.js b/test/flags.js
index 31672844bf..6aeb162703 100644
--- a/test/flags.js
+++ b/test/flags.js
@@ -16,7 +16,6 @@ var Meta = require('../src/meta');
describe('Flags', function () {
before(function (done) {
- Groups.resetCache();
// Create some stuff to flag
async.waterfall([
async.apply(User.create, { username: 'testUser', password: 'abcdef', email: 'b@c.com' }),
diff --git a/test/groups.js b/test/groups.js
index fa81e33462..eb0ed77378 100644
--- a/test/groups.js
+++ b/test/groups.js
@@ -14,7 +14,6 @@ describe('Groups', function () {
var adminUid;
var testUid;
before(function (done) {
- Groups.resetCache();
async.series([
function (next) {
// Create a group to play around with
diff --git a/test/messaging.js b/test/messaging.js
index 26ae4d867c..b80a3c5fe7 100644
--- a/test/messaging.js
+++ b/test/messaging.js
@@ -20,7 +20,6 @@ describe('Messaging Library', function () {
var roomId;
before(function (done) {
- Groups.resetCache();
// Create 3 users: 1 admin, 2 regular
async.series([
async.apply(User.create, { username: 'foo', password: 'barbar' }), // admin
diff --git a/test/mocks/databasemock.js b/test/mocks/databasemock.js
index 0aaab1647e..47f3e37b72 100644
--- a/test/mocks/databasemock.js
+++ b/test/mocks/databasemock.js
@@ -25,7 +25,7 @@ nconf.defaults({
if (!nconf.get('isCluster')) {
nconf.set('isPrimary', 'true');
- nconf.set('isCluster', 'false');
+ nconf.set('isCluster', 'true');
}
var dbType = nconf.get('database');
@@ -59,6 +59,14 @@ if (!testDbConfig) {
' "password": "",\n' +
' "database": "nodebb_test"\n' +
'}\n' +
+ ' or (postgres):\n' +
+ '"test_database": {\n' +
+ ' "host": "127.0.0.1",\n' +
+ ' "port": "5432",\n' +
+ ' "username": "postgres",\n' +
+ ' "password": "",\n' +
+ ' "database": "nodebb_test"\n' +
+ '}\n' +
'==========================================================='
);
winston.error(errorText);
@@ -153,6 +161,11 @@ function setupMockDefaults(callback) {
function (next) {
db.emptydb(next);
},
+ function (next) {
+ var groups = require('../../src/groups');
+ groups.resetCache();
+ next();
+ },
function (next) {
winston.info('test_database flushed');
setupDefaultConfigs(meta, next);
@@ -205,7 +218,10 @@ function setupDefaultConfigs(meta, next) {
function giveDefaultGlobalPrivileges(next) {
var privileges = require('../../src/privileges');
- privileges.global.give(['chat', 'upload:post:image', 'signature', 'search:content', 'search:users', 'search:tags'], 'registered-users', next);
+ privileges.global.give([
+ 'chat', 'upload:post:image', 'signature', 'search:content',
+ 'search:users', 'search:tags', 'local:login',
+ ], 'registered-users', next);
}
function enableDefaultPlugins(callback) {
diff --git a/test/notifications.js b/test/notifications.js
index a937510012..ab495aae2f 100644
--- a/test/notifications.js
+++ b/test/notifications.js
@@ -18,7 +18,6 @@ describe('Notifications', function () {
var notification;
before(function (done) {
- groups.resetCache();
user.create({ username: 'poster' }, function (err, _uid) {
if (err) {
return done(err);
diff --git a/test/plugins.js b/test/plugins.js
index e948cbb160..d10642a2e0 100644
--- a/test/plugins.js
+++ b/test/plugins.js
@@ -116,10 +116,9 @@ describe('Plugins', function () {
var latest;
var pluginName = 'nodebb-plugin-imgur';
it('should install a plugin', function (done) {
- this.timeout(20000);
+ this.timeout(0);
plugins.toggleInstall(pluginName, '1.0.16', function (err, pluginData) {
assert.ifError(err);
-
latest = pluginData.latest;
assert.equal(pluginData.name, pluginName);
@@ -148,6 +147,7 @@ describe('Plugins', function () {
});
it('should upgrade plugin', function (done) {
+ this.timeout(0);
plugins.upgrade(pluginName, 'latest', function (err, isActive) {
assert.ifError(err);
assert(isActive);
@@ -160,6 +160,7 @@ describe('Plugins', function () {
});
it('should uninstall a plugin', function (done) {
+ this.timeout(0);
plugins.toggleInstall(pluginName, 'latest', function (err, pluginData) {
assert.ifError(err);
assert.equal(pluginData.installed, false);
diff --git a/test/posts.js b/test/posts.js
index f715f22274..fa029c26cb 100644
--- a/test/posts.js
+++ b/test/posts.js
@@ -30,7 +30,6 @@ describe('Post\'s', function () {
var cid;
before(function (done) {
- groups.resetCache();
async.series({
voterUid: function (next) {
user.create({ username: 'upvoter' }, next);
diff --git a/test/pubsub.js b/test/pubsub.js
new file mode 100644
index 0000000000..ddec29b900
--- /dev/null
+++ b/test/pubsub.js
@@ -0,0 +1,34 @@
+'use strict';
+
+var assert = require('assert');
+var nconf = require('nconf');
+
+var db = require('./mocks/databasemock');
+var pubsub = require('../src/pubsub');
+
+describe('pubsub', function () {
+ it('should use singleHostCluster', function (done) {
+ var oldValue = nconf.get('singleHostCluster');
+ nconf.set('singleHostCluster', true);
+ pubsub.on('testEvent', function (message) {
+ assert.equal(message.foo, 1);
+ nconf.set('singleHostCluster', oldValue);
+
+ pubsub.removeAllListeners('testEvent');
+ done();
+ });
+ pubsub.publish('testEvent', { foo: 1 });
+ });
+
+ it('should use the current database\'s pubsub', function (done) {
+ var oldValue = nconf.get('singleHostCluster');
+ nconf.set('singleHostCluster', false);
+ pubsub.on('testEvent', function (message) {
+ assert.equal(message.foo, 1);
+ nconf.set('singleHostCluster', oldValue);
+ pubsub.removeAllListeners('testEvent');
+ done();
+ });
+ pubsub.publish('testEvent', { foo: 1 });
+ });
+});
diff --git a/test/rewards.js b/test/rewards.js
index d707eb5f4c..99b85e0681 100644
--- a/test/rewards.js
+++ b/test/rewards.js
@@ -14,12 +14,11 @@ describe('rewards', function () {
var herpUid;
before(function (done) {
- Groups.resetCache();
// Create 3 users: 1 admin, 2 regular
async.series([
- async.apply(User.create, { username: 'foo', password: 'barbar' }),
- async.apply(User.create, { username: 'baz', password: 'quuxquux' }),
- async.apply(User.create, { username: 'herp', password: 'derpderp' }),
+ async.apply(User.create, { username: 'foo' }),
+ async.apply(User.create, { username: 'baz' }),
+ async.apply(User.create, { username: 'herp' }),
], function (err, uids) {
if (err) {
return done(err);
diff --git a/test/socket.io.js b/test/socket.io.js
index cb50b51a13..31be159051 100644
--- a/test/socket.io.js
+++ b/test/socket.io.js
@@ -42,7 +42,7 @@ describe('socket.io', function () {
adminUid = data[0];
regularUid = data[1];
cid = data[2].cid;
- groups.resetCache();
+
groups.join('administrators', data[0], done);
});
});
@@ -198,11 +198,13 @@ describe('socket.io', function () {
it('should delete users', function (done) {
socketAdmin.user.deleteUsers({ uid: adminUid }, [uid], function (err) {
assert.ifError(err);
- groups.isMember(uid, 'registered-users', function (err, isMember) {
- assert.ifError(err);
- assert(!isMember);
- done();
- });
+ setTimeout(function () {
+ groups.isMember(uid, 'registered-users', function (err, isMember) {
+ assert.ifError(err);
+ assert(!isMember);
+ done();
+ });
+ }, 500);
});
});
@@ -499,6 +501,7 @@ describe('socket.io', function () {
});
it('should upgrade plugin', function (done) {
+ this.timeout(0);
socketAdmin.plugins.upgrade({ uid: adminUid }, { id: 'nodebb-plugin-location-to-map', version: 'latest' }, function (err) {
assert.ifError(err);
done();
diff --git a/test/topics.js b/test/topics.js
index 5629fdf27a..f8e4e95087 100644
--- a/test/topics.js
+++ b/test/topics.js
@@ -23,7 +23,6 @@ describe('Topic\'s', function () {
var adminUid;
before(function (done) {
- groups.resetCache();
User.create({ username: 'admin', password: '123456' }, function (err, uid) {
if (err) {
return done(err);
@@ -404,6 +403,120 @@ describe('Topic\'s', function () {
});
});
+ it('should properly update sets when post is moved', function (done) {
+ var movedPost;
+ var previousPost;
+ var topic2LastReply;
+ var tid1;
+ var tid2;
+ var cid1 = topic.categoryId;
+ var cid2;
+ function checkCidSets(post1, post2, callback) {
+ async.waterfall([
+ function (next) {
+ async.parallel({
+ topicData: function (next) {
+ topics.getTopicsFields([tid1, tid2], ['lastposttime', 'postcount'], next);
+ },
+ scores1: function (next) {
+ db.sortedSetsScore([
+ 'cid:' + cid1 + ':tids',
+ 'cid:' + cid1 + ':tids:lastposttime',
+ 'cid:' + cid1 + ':tids:posts',
+ ], tid1, next);
+ },
+ scores2: function (next) {
+ db.sortedSetsScore([
+ 'cid:' + cid2 + ':tids',
+ 'cid:' + cid2 + ':tids:lastposttime',
+ 'cid:' + cid2 + ':tids:posts',
+ ], tid2, next);
+ },
+ posts1: function (next) {
+ db.getSortedSetRangeWithScores('tid:' + tid1 + ':posts', 0, -1, next);
+ },
+ posts2: function (next) {
+ db.getSortedSetRangeWithScores('tid:' + tid2 + ':posts', 0, -1, next);
+ },
+ }, next);
+ },
+ function (results, next) {
+ var assertMsg = JSON.stringify(results.posts1) + '\n' + JSON.stringify(results.posts2);
+ assert.equal(results.topicData[0].postcount, results.scores1[2], assertMsg);
+ assert.equal(results.topicData[1].postcount, results.scores2[2], assertMsg);
+ assert.equal(results.topicData[0].lastposttime, post1.timestamp, assertMsg);
+ assert.equal(results.topicData[1].lastposttime, post2.timestamp, assertMsg);
+ assert.equal(results.topicData[0].lastposttime, results.scores1[0], assertMsg);
+ assert.equal(results.topicData[1].lastposttime, results.scores2[0], assertMsg);
+ assert.equal(results.topicData[0].lastposttime, results.scores1[1], assertMsg);
+ assert.equal(results.topicData[1].lastposttime, results.scores2[1], assertMsg);
+
+ next();
+ },
+ ], callback);
+ }
+
+ async.waterfall([
+ function (next) {
+ categories.create({
+ name: 'move to this category',
+ description: 'Test category created by testing script',
+ }, next);
+ },
+ function (category, next) {
+ cid2 = category.cid;
+ topics.post({ uid: adminUid, title: 'topic1', content: 'topic 1 mainPost', cid: cid1 }, next);
+ },
+ function (result, next) {
+ tid1 = result.topicData.tid;
+ topics.reply({ uid: adminUid, content: 'topic 1 reply 1', tid: tid1 }, next);
+ },
+ function (postData, next) {
+ previousPost = postData;
+ topics.reply({ uid: adminUid, content: 'topic 1 reply 2', tid: tid1 }, next);
+ },
+ function (postData, next) {
+ movedPost = postData;
+ topics.post({ uid: adminUid, title: 'topic2', content: 'topic 2 mainpost', cid: cid2 }, next);
+ },
+ function (results, next) {
+ tid2 = results.topicData.tid;
+ topics.reply({ uid: adminUid, content: 'topic 2 reply 1', tid: tid2 }, next);
+ },
+ function (postData, next) {
+ topic2LastReply = postData;
+ checkCidSets(movedPost, postData, next);
+ },
+ function (next) {
+ db.isMemberOfSortedSets(['cid:' + cid1 + ':pids', 'cid:' + cid2 + ':pids'], movedPost.pid, next);
+ },
+ function (isMember, next) {
+ assert.deepEqual(isMember, [true, false]);
+ categories.getCategoriesFields([cid1, cid2], ['post_count'], next);
+ },
+ function (categoryData, next) {
+ assert.equal(categoryData[0].post_count, 4);
+ assert.equal(categoryData[1].post_count, 2);
+ topics.movePostToTopic(movedPost.pid, tid2, next);
+ },
+ function (next) {
+ checkCidSets(previousPost, topic2LastReply, next);
+ },
+ function (next) {
+ db.isMemberOfSortedSets(['cid:' + cid1 + ':pids', 'cid:' + cid2 + ':pids'], movedPost.pid, next);
+ },
+ function (isMember, next) {
+ assert.deepEqual(isMember, [false, true]);
+ categories.getCategoriesFields([cid1, cid2], ['post_count'], next);
+ },
+ function (categoryData, next) {
+ assert.equal(categoryData[0].post_count, 3);
+ assert.equal(categoryData[1].post_count, 3);
+ next();
+ },
+ ], done);
+ });
+
it('should purge the topic', function (done) {
socketTopics.purge({ uid: 1 }, { tids: [newTopic.tid], cid: categoryObj.cid }, function (err) {
assert.ifError(err);
@@ -795,8 +908,8 @@ describe('Topic\'s', function () {
request(nconf.get('url') + '/api/topic/' + topicData.slug + '/-1', { json: true }, function (err, res, body) {
assert.ifError(err);
assert.equal(res.statusCode, 200);
- assert.equal(res.headers['x-redirect'], '/topic/13/topic-for-controller-test');
- assert.equal(body, '/topic/13/topic-for-controller-test');
+ assert.equal(res.headers['x-redirect'], '/topic/15/topic-for-controller-test');
+ assert.equal(body, '/topic/15/topic-for-controller-test');
done();
});
});
@@ -981,7 +1094,7 @@ describe('Topic\'s', function () {
var tid1;
var tid3;
before(function (done) {
- async.parallel({
+ async.series({
topic1: function (next) {
topics.post({ uid: adminUid, tags: ['nodebb'], title: 'topic title 1', content: 'topic 1 content', cid: topic.categoryId }, next);
},
@@ -1063,7 +1176,6 @@ describe('Topic\'s', function () {
});
});
-
it('should fail with invalid data', function (done) {
socketTopics.markAsRead({ uid: 0 }, null, function (err) {
assert.equal(err.message, '[[error:invalid-data]]');
@@ -1071,7 +1183,6 @@ describe('Topic\'s', function () {
});
});
-
it('should mark topic read', function (done) {
socketTopics.markAsRead({ uid: adminUid }, [tid], function (err) {
assert.ifError(err);
@@ -1156,7 +1267,6 @@ describe('Topic\'s', function () {
});
});
-
it('should fail with invalid data', function (done) {
socketTopics.markAsUnreadForAll({ uid: adminUid }, null, function (err) {
assert.equal(err.message, '[[error:invalid-tid]]');
@@ -1211,6 +1321,35 @@ describe('Topic\'s', function () {
done();
});
});
+
+ it('should not return topics in category you cant read', function (done) {
+ var privateCid;
+ var privateTid;
+ async.waterfall([
+ function (next) {
+ categories.create({
+ name: 'private category',
+ description: 'private category',
+ }, next);
+ },
+ function (category, next) {
+ privateCid = category.cid;
+ privileges.categories.rescind(['read'], category.cid, 'registered-users', next);
+ },
+ function (next) {
+ topics.post({ uid: adminUid, title: 'topic in private category', content: 'registered-users cant see this', cid: privateCid }, next);
+ },
+ function (data, next) {
+ privateTid = data.topicData.tid;
+ topics.getUnreadTids({ uid: uid }, next);
+ },
+ function (unreadTids, next) {
+ unreadTids = unreadTids.map(String);
+ assert(!unreadTids.includes(String(privateTid)));
+ next();
+ },
+ ], done);
+ });
});
describe('tags', function () {
@@ -1218,14 +1357,14 @@ describe('Topic\'s', function () {
var socketAdmin = require('../src/socket.io/admin');
before(function (done) {
- async.parallel({
- topic1: function (next) {
+ async.series([
+ function (next) {
topics.post({ uid: adminUid, tags: ['php', 'nosql', 'psql', 'nodebb'], title: 'topic title 1', content: 'topic 1 content', cid: topic.categoryId }, next);
},
- topic2: function (next) {
+ function (next) {
topics.post({ uid: adminUid, tags: ['javascript', 'mysql', 'python', 'nodejs'], title: 'topic title 2', content: 'topic 2 content', cid: topic.categoryId }, next);
},
- }, function (err) {
+ ], function (err) {
assert.ifError(err);
done();
});
@@ -1386,7 +1525,7 @@ describe('Topic\'s', function () {
});
it('should rename tags', function (done) {
- async.parallel({
+ async.series({
topic1: function (next) {
topics.post({ uid: adminUid, tags: ['plugins'], title: 'topic tagged with plugins', content: 'topic 1 content', cid: topic.categoryId }, next);
},
diff --git a/test/uploads.js b/test/uploads.js
index bd6b50be89..9da7056941 100644
--- a/test/uploads.js
+++ b/test/uploads.js
@@ -15,6 +15,8 @@ var privileges = require('../src/privileges');
var meta = require('../src/meta');
var socketUser = require('../src/socket.io/user');
var helpers = require('./helpers');
+var file = require('../src/file');
+var image = require('../src/image');
describe('Upload Controllers', function () {
var tid;
@@ -113,6 +115,7 @@ describe('Upload Controllers', function () {
assert.equal(res.statusCode, 200);
assert(Array.isArray(body));
assert(body[0].url);
+ assert(body[0].url.match(/\/assets\/uploads\/files\/\d+-test-resized\.png/));
meta.config.maximumImageWidth = oldValue;
done();
});
@@ -133,6 +136,38 @@ describe('Upload Controllers', function () {
});
});
+ it('should fail to upload image to post if image dimensions are too big', function (done) {
+ helpers.uploadFile(nconf.get('url') + '/api/post/upload', path.join(__dirname, '../test/files/toobig.jpg'), {}, jar, csrf_token, function (err, res, body) {
+ assert.ifError(err);
+ assert.equal(res.statusCode, 500);
+ assert(body.error, '[[error:invalid-image-dimensions]]');
+ done();
+ });
+ });
+
+ it('should fail to upload image to post if image is broken', function (done) {
+ helpers.uploadFile(nconf.get('url') + '/api/post/upload', path.join(__dirname, '../test/files/brokenimage.png'), {}, jar, csrf_token, function (err, res, body) {
+ assert.ifError(err);
+ assert.equal(res.statusCode, 500);
+ assert(body.error, 'invalid block type');
+ done();
+ });
+ });
+
+ it('should fail if file is not an image', function (done) {
+ file.isFileTypeAllowed(path.join(__dirname, '../test/files/notanimage.png'), function (err) {
+ assert.equal(err.message, 'Input file is missing or of an unsupported image format');
+ done();
+ });
+ });
+
+ it('should fail if file is not an image', function (done) {
+ image.size(path.join(__dirname, '../test/files/notanimage.png'), function (err) {
+ assert.equal(err.message, 'Input file is missing or of an unsupported image format');
+ done();
+ });
+ });
+
it('should fail if topic thumbs are disabled', function (done) {
helpers.uploadFile(nconf.get('url') + '/api/topic/thumb/upload', path.join(__dirname, '../test/files/test.png'), {}, jar, csrf_token, function (err, res, body) {
assert.ifError(err);
@@ -202,7 +237,7 @@ describe('Upload Controllers', function () {
user.delete(1, uid, next);
},
- function (next) {
+ function (userData, next) {
var filePath = path.join(nconf.get('upload_path'), url.replace('/assets/uploads', ''));
file.exists(filePath, next);
},
diff --git a/test/user.js b/test/user.js
index 96b47921c6..6a0b961279 100644
--- a/test/user.js
+++ b/test/user.js
@@ -23,8 +23,6 @@ describe('User', function () {
var testCid;
before(function (done) {
- groups.resetCache();
-
Categories.create({
name: 'Test Category',
description: 'A test',
@@ -677,7 +675,7 @@ describe('User', function () {
assert.ifError(err);
socketUser.changePassword({ uid: uid }, { uid: uid, newPassword: '654321', currentPassword: '123456' }, function (err) {
assert.ifError(err);
- User.isPasswordCorrect(uid, '654321', function (err, correct) {
+ User.isPasswordCorrect(uid, '654321', '127.0.0.1', function (err, correct) {
assert.ifError(err);
assert(correct);
done();
@@ -702,7 +700,7 @@ describe('User', function () {
assert.ifError(err);
db.getSortedSetRevRange('user:' + uid + ':usernames', 0, -1, function (err, data) {
assert.ifError(err);
- assert.equal(data.length, 1);
+ assert.equal(data.length, 2);
assert(data[0].startsWith('updatedAgain'));
done();
});