diff --git a/app.js b/app.js index d4cc855de9..85abee4ab2 100644 --- a/app.js +++ b/app.js @@ -37,7 +37,7 @@ winston.add(winston.transports.Console, { colorize: true, timestamp: function () { var date = new Date(); - return (!!nconf.get('json-logging')) ? date.toJSON() : date.getDate() + '/' + (date.getMonth() + 1) + ' ' + date.toTimeString().substr(0,5) + ' [' + global.process.pid + ']'; + return (!!nconf.get('json-logging')) ? date.toJSON() : date.getDate() + '/' + (date.getMonth() + 1) + ' ' + date.toTimeString().substr(0,8) + ' [' + global.process.pid + ']'; }, level: nconf.get('log-level') || (global.env === 'production' ? 'info' : 'verbose'), json: (!!nconf.get('json-logging')), @@ -180,21 +180,26 @@ function start() { }); async.waterfall([ - async.apply(db.init), - async.apply(db.checkCompatibility), - function (next) { - require('./src/meta').configs.init(next); - }, + async.apply(db.init), function (next) { - if (nconf.get('dep-check') === undefined || nconf.get('dep-check') !== false) { - require('./src/meta').dependencies.check(next); - } else { - winston.warn('[init] Dependency checking skipped!'); - setImmediate(next); - } - }, - function (next) { - require('./src/upgrade').check(next); + var meta = require('./src/meta'); + async.parallel([ + async.apply(db.checkCompatibility), + async.apply(meta.configs.init), + function (next) { + if (nconf.get('dep-check') === undefined || nconf.get('dep-check') !== false) { + meta.dependencies.check(next); + } else { + winston.warn('[init] Dependency checking skipped!'); + setImmediate(next); + } + }, + function (next) { + require('./src/upgrade').check(next); + } + ], function (err) { + next(err); + }); }, function (next) { var webserver = require('./src/webserver'); diff --git a/package.json b/package.json index a897b57966..bea679984d 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "morgan": "^1.3.2", "mousetrap": "^1.5.3", "nconf": "~0.8.2", - "nodebb-plugin-composer-default": "4.3.2", + "nodebb-plugin-composer-default": "4.3.3", "nodebb-plugin-dbsearch": "1.0.4", "nodebb-plugin-emoji-extended": "1.1.1", "nodebb-plugin-emoji-one": "1.1.5", diff --git a/public/src/admin/manage/category.js b/public/src/admin/manage/category.js index bc0b1652b5..866bc4bb9d 100644 --- a/public/src/admin/manage/category.js +++ b/public/src/admin/manage/category.js @@ -8,44 +8,9 @@ define('admin/manage/category', [ 'autocomplete' ], function (uploader, iconSelect, colorpicker, autocomplete) { var Category = {}; + var modified_categories = {}; Category.init = function () { - var modified_categories = {}; - - function modified(el) { - var cid = $(el).parents('form').attr('data-cid'); - - if (cid) { - modified_categories[cid] = modified_categories[cid] || {}; - modified_categories[cid][$(el).attr('data-name')] = $(el).val(); - - app.flags = app.flags || {}; - app.flags._unsaved = true; - } - } - - function save(e) { - e.preventDefault(); - - if(Object.keys(modified_categories).length) { - socket.emit('admin.categories.update', modified_categories, function (err, result) { - if (err) { - return app.alertError(err.message); - } - - if (result && result.length) { - app.flags._unsaved = false; - app.alert({ - title: 'Updated Categories', - message: 'Category IDs ' + result.join(', ') + ' was successfully updated.', - type: 'success', - timeout: 2000 - }); - } - }); - modified_categories = {}; - } - } $('.blockclass, form.category select').each(function () { var $this = $(this); @@ -85,7 +50,28 @@ define('admin/manage/category', [ $('[data-name="bgColor"], [data-name="color"]').each(enableColorPicker); - $('#save').on('click', save); + $('#save').on('click', function () { + if (Object.keys(modified_categories).length) { + socket.emit('admin.categories.update', modified_categories, function (err, result) { + if (err) { + return app.alertError(err.message); + } + + if (result && result.length) { + app.flags._unsaved = false; + app.alert({ + title: 'Updated Categories', + message: 'Category IDs ' + result.join(', ') + ' was successfully updated.', + type: 'success', + timeout: 2000 + }); + } + }); + modified_categories = {}; + } + return false; + }); + $('.purge').on('click', function (e) { e.preventDefault(); @@ -171,8 +157,37 @@ define('admin/manage/category', [ }); Category.setupPrivilegeTable(); + + handleTags(); }; + function modified(el) { + var cid = ajaxify.data.category.cid; + + if (cid) { + modified_categories[cid] = modified_categories[cid] || {}; + modified_categories[cid][$(el).attr('data-name')] = $(el).val(); + + app.flags = app.flags || {}; + app.flags._unsaved = true; + } + } + + function handleTags() { + var tagEl = $('#tag-whitelist'); + tagEl.tagsinput({ + confirmKeys: [13, 44], + trimValue: true + }); + + ajaxify.data.category.tagWhitelist.forEach(function (tag) { + tagEl.tagsinput('add', tag); + }); + tagEl.on('itemAdded itemRemoved', function (event) { + modified(tagEl); + }); + } + Category.setupPrivilegeTable = function () { $('.privilege-table-container').on('change', 'input[type="checkbox"]', function () { var checkboxEl = $(this), diff --git a/public/src/utils.js b/public/src/utils.js index 0b78b34030..42d4b6f3ad 100644 --- a/public/src/utils.js +++ b/public/src/utils.js @@ -101,7 +101,7 @@ }, cleanUpTag: function (tag, maxLength) { - if (typeof tag !== 'string' || !tag.length ) { + if (typeof tag !== 'string' || !tag.length) { return ''; } diff --git a/src/categories.js b/src/categories.js index 28d99a7d7f..4458e09481 100644 --- a/src/categories.js +++ b/src/categories.js @@ -141,6 +141,9 @@ var privileges = require('./privileges'); parents: function (next) { Categories.getParents(cids, next); }, + tagWhitelist: function (next) { + Categories.getTagWhitelist(cids, next); + }, hasRead: function (next) { Categories.hasReadCategories(cids, uid, next); } @@ -149,20 +152,26 @@ var privileges = require('./privileges'); return callback(err); } - var categories = results.categories; - var hasRead = results.hasRead; uid = parseInt(uid, 10); - for(var i = 0; i < results.categories.length; ++i) { - if (categories[i]) { - categories[i]['unread-class'] = (parseInt(categories[i].topic_count, 10) === 0 || (hasRead[i] && uid !== 0)) ? '' : 'unread'; - categories[i].children = results.children[i]; - categories[i].parent = results.parents[i] || undefined; - calculateTopicPostCount(categories[i]); + results.categories.forEach(function (category, i) { + if (category) { + category['unread-class'] = (parseInt(category.topic_count, 10) === 0 || (results.hasRead[i] && uid !== 0)) ? '' : 'unread'; + category.children = results.children[i]; + category.parent = results.parents[i] || undefined; + category.tagWhitelist = results.tagWhitelist[i]; + calculateTopicPostCount(category); } - } + }); + + callback(null, results.categories); + }); + }; - callback(null, categories); + Categories.getTagWhitelist = function (cids, callback) { + var keys = cids.map(function (cid) { + return 'cid:' + cid + ':tag:whitelist'; }); + db.getSortedSetsMembers(keys, callback); }; function calculateTopicPostCount(category) { diff --git a/src/categories/delete.js b/src/categories/delete.js index f91842e487..06d4931132 100644 --- a/src/categories/delete.js +++ b/src/categories/delete.js @@ -54,6 +54,7 @@ module.exports = function (Categories) { 'cid:' + cid + ':read_by_uid', 'cid:' + cid + ':ignorers', 'cid:' + cid + ':children', + 'cid:' + cid + ':tag:whitelist', 'category:' + cid ], next); }, diff --git a/src/categories/update.js b/src/categories/update.js index 485d3e3834..afaad6d974 100644 --- a/src/categories/update.js +++ b/src/categories/update.js @@ -2,7 +2,9 @@ 'use strict'; var async = require('async'); + var db = require('../database'); +var meta = require('../meta'); var utils = require('../../public/src/utils'); var translator = require('../../public/src/modules/translator'); var plugins = require('../plugins'); @@ -66,6 +68,8 @@ module.exports = function (Categories) { function updateCategoryField(cid, key, value, callback) { if (key === 'parentCid') { return updateParent(cid, value, callback); + } else if (key === 'tagWhitelist') { + return updateTagWhitelist(cid, value, callback); } async.waterfall([ @@ -112,6 +116,25 @@ module.exports = function (Categories) { }); } + function updateTagWhitelist(cid, tags, callback) { + tags = tags.split(','); + tags = tags.map(function (tag) { + return utils.cleanUpTag(tag, meta.config.maximumTagLength); + }).filter(Boolean); + + async.waterfall([ + function (next) { + db.delete('cid:' + cid + ':tag:whitelist', next); + }, + function (next) { + var scores = tags.map(function (tag, index) { + return index; + }); + db.sortedSetAdd('cid:' + cid + ':tag:whitelist', scores, tags, next); + } + ], callback); + } + function updateOrder(cid, order, callback) { async.waterfall([ function (next) { diff --git a/src/database/mongo.js b/src/database/mongo.js index 1528671770..2e09047da1 100644 --- a/src/database/mongo.js +++ b/src/database/mongo.js @@ -111,13 +111,11 @@ if (err) { return callback(err); } - createSessionStore(); - createIndices(); + createSessionStore(); }); } else { winston.warn('You have no mongo password setup!'); createSessionStore(); - createIndices(); } function createSessionStore() { @@ -137,37 +135,44 @@ db: db }); } - } + callback(); + } + }); + }; - function createIndices() { - winston.info('[database] Checking database indices.'); - async.series([ - async.apply(createIndex, 'objects', {_key: 1, score: -1}, {background: true}), - async.apply(createIndex, 'objects', {_key: 1, value: -1}, {background: true, unique: true, sparse: true}), - async.apply(createIndex, 'objects', {expireAt: 1}, {expireAfterSeconds: 0, background: true}) - ], function (err) { - if (err) { - winston.error('Error creating index ' + err.message); - } - winston.info('[database] Checking database indices done!'); - callback(err); - }); - } + module.createIndices = function (callback) { + function createIndex(collection, index, options, callback) { + module.client.collection(collection).createIndex(index, options, callback); + } - function createIndex(collection, index, options, callback) { - db.collection(collection).createIndex(index, options, callback); + if (!module.client) { + winston.warn('[database/createIndices] database not initialized'); + return callback(); + } + + winston.info('[database] Checking database indices.'); + async.series([ + async.apply(createIndex, 'objects', {_key: 1, score: -1}, {background: true}), + async.apply(createIndex, 'objects', {_key: 1, value: -1}, {background: true, unique: true, sparse: true}), + async.apply(createIndex, 'objects', {expireAt: 1}, {expireAfterSeconds: 0, background: true}) + ], function (err) { + if (err) { + winston.error('Error creating index ' + err.message); + return callback(err); } - }); + winston.info('[database] Checking database indices done!'); + callback(); + }); }; module.checkCompatibility = function (callback) { - var mongoPkg = require.main.require('./node_modules/mongodb/package.json'), - err = semver.lt(mongoPkg.version, '2.0.0') ? new Error('The `mongodb` package is out-of-date, please run `./nodebb setup` again.') : null; - - if (err) { - err.stacktrace = false; + var mongoPkg = require.main.require('./node_modules/mongodb/package.json'); + + if (semver.lt(mongoPkg.version, '2.0.0')) { + return callback(new Error('The `mongodb` package is out-of-date, please run `./nodebb setup` again.')); } - callback(err); + + callback(); }; module.info = function (db, callback) { diff --git a/src/database/redis.js b/src/database/redis.js index e583aff4ff..f1c00a2316 100644 --- a/src/database/redis.js +++ b/src/database/redis.js @@ -2,13 +2,13 @@ (function (module) { - var winston = require('winston'), - nconf = require('nconf'), - semver = require('semver'), - session = require('express-session'), - redis, - connectRedis, - redisClient; + var winston = require('winston'); + var nconf = require('nconf'); + var semver = require('semver'); + var session = require('express-session'); + var redis; + var connectRedis; + var redisClient; module.questions = [ { @@ -107,6 +107,10 @@ return cxn; }; + module.createIndices = function (callback) { + setImmediate(callback); + }; + module.checkCompatibility = function (callback) { module.info(module.client, function (err, info) { if (err) { @@ -114,11 +118,10 @@ } if (semver.lt(info.redis_version, '2.8.9')) { - err = new Error('Your Redis version is not new enough to support NodeBB, please upgrade Redis to v2.8.9 or higher.'); - err.stacktrace = false; + return callback(new Error('Your Redis version is not new enough to support NodeBB, please upgrade Redis to v2.8.9 or higher.')); } - callback(err); + callback(); }); }; diff --git a/src/install.js b/src/install.js index 711932bb00..acf7aadc97 100644 --- a/src/install.js +++ b/src/install.js @@ -1,16 +1,15 @@ 'use strict'; -var async = require('async'), - fs = require('fs'), - path = require('path'), - prompt = require('prompt'), - winston = require('winston'), - nconf = require('nconf'), - utils = require('../public/src/utils.js'); +var async = require('async'); +var fs = require('fs'); +var path = require('path'); +var prompt = require('prompt'); +var winston = require('winston'); +var nconf = require('nconf'); +var utils = require('../public/src/utils.js'); - -var install = {}, - questions = {}; +var install = {}; +var questions = {}; questions.main = [ { @@ -124,40 +123,33 @@ function setupConfig(next) { prompt.delimiter = ''; prompt.colors = false; - if (!install.values) { - prompt.get(questions.main, function (err, config) { - if (err) { - process.stdout.write('\n\n'); - winston.warn('NodeBB setup ' + err.message); - process.exit(); + async.waterfall([ + function (next) { + if (install.values) { + // Use provided values, fall back to defaults + 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); + + allQuestions.forEach(function (question) { + config[question.name] = install.values[question.name] || question['default'] || undefined; + }); + setImmediate(next, null, config); + } else { + prompt.get(questions.main, next); } - - configureDatabases(config, function (err, config) { - completeConfigSetup(err, config, next); - }); - }); - } else { - // Use provided values, fall back to defaults - var config = {}, - redisQuestions = require('./database/redis').questions, - mongoQuestions = require('./database/mongo').questions, - allQuestions = questions.main.concat(questions.optional).concat(redisQuestions).concat(mongoQuestions); - - allQuestions.forEach(function (question) { - config[question.name] = install.values[question.name] || question['default'] || undefined; - }); - - configureDatabases(config, function (err, config) { - completeConfigSetup(err, config, next); - }); - } + }, + function (config, next) { + configureDatabases(config, next); + }, + function (config, next) { + completeConfigSetup(config, next); + } + ], next); } -function completeConfigSetup(err, config, next) { - if (err) { - return next(err); - } - +function completeConfigSetup(config, next) { // Add CI object if (install.ciVals) { config.test_database = {}; @@ -168,13 +160,17 @@ function completeConfigSetup(err, config, next) { } } - install.save(config, function (err) { - if (err) { - return next(err); + async.waterfall([ + function (next) { + install.save(config, next); + }, + function (next) { + require('./database').init(next); + }, + function (next) { + require('./database').createIndices(next); } - - require('./database').init(next); - }); + ], next); } function setupDefaultConfigs(next) { @@ -491,7 +487,6 @@ function setCopyrightWidget(next) { install.setup = function (callback) { - async.series([ checkSetupFlag, checkCIFlag, diff --git a/src/meta/css.js b/src/meta/css.js index 583927e04a..b9bc1787b6 100644 --- a/src/meta/css.js +++ b/src/meta/css.js @@ -78,6 +78,7 @@ module.exports = function (Meta) { acpSource += '\n@import "..' + path.sep + 'public/less/generics.less";\n'; acpSource += '\n@import (inline) "..' + path.sep + 'public/vendor/colorpicker/colorpicker.css";\n'; acpSource += '\n@import (inline) "..' + path.sep + 'public/vendor/jquery/css/smoothness/jquery-ui.css";'; + acpSource += '\n@import (inline) "..' + path.sep + 'public/vendor/jquery/bootstrap-tagsinput/bootstrap-tagsinput.css";'; minify(acpSource, paths, 'acpCache', callback); } diff --git a/src/socket.io/topics/tags.js b/src/socket.io/topics/tags.js index 67e48fcce8..8829229ca8 100644 --- a/src/socket.io/topics/tags.js +++ b/src/socket.io/topics/tags.js @@ -1,10 +1,29 @@ 'use strict'; var async = require('async'); +var db = require('../../database'); var topics = require('../../topics'); var utils = require('../../../public/src/utils'); module.exports = function (SocketTopics) { + + SocketTopics.isTagAllowed = function (socket, data, callback) { + if (!data || !data.cid || !data.tag) { + return callback(new Error('[[error:invalid-data]]')); + } + async.waterfall([ + function (next) { + db.getSortedSetRange('cid:' + data.cid + ':tag:whitelist', 0, -1, next); + }, + function (tagWhitelist, next) { + if (!tagWhitelist.length) { + return next(null, true); + } + next(null, tagWhitelist.indexOf(data.tag) !== -1); + } + ], callback); + }; + SocketTopics.autocompleteTags = function (socket, data, callback) { topics.autocompleteTags(data, callback); }; diff --git a/src/topics/tags.js b/src/topics/tags.js index a88c5119d5..3f6bfbab36 100644 --- a/src/topics/tags.js +++ b/src/topics/tags.js @@ -31,6 +31,10 @@ module.exports = function (Topics) { return tag && tag.length >= (meta.config.minimumTagLength || 3) && array.indexOf(tag) === index; }); + filterCategoryTags(tags, tid, next); + }, + function (_tags, next) { + tags = _tags; var keys = tags.map(function (tag) { return 'tag:' + tag + ':topics'; }); @@ -39,15 +43,35 @@ module.exports = function (Topics) { async.apply(db.setAdd, 'topic:' + tid + ':tags', tags), async.apply(db.sortedSetsAdd, keys, timestamp, tid) ], function (err) { - if (err) { - return next(err); - } - async.each(tags, updateTagCount, next); + next(err); }); + }, + function (next) { + async.each(tags, updateTagCount, next); } ], callback); }; + function filterCategoryTags(tags, tid, callback) { + async.waterfall([ + function (next) { + Topics.getTopicField(tid, 'cid', next); + }, + function (cid, next) { + db.getSortedSetRange('cid:' + cid + ':tag:whitelist', 0, -1, next); + }, + function (tagWhitelist, next) { + if (!tagWhitelist.length) { + return next(null, tags); + } + tags = tags.filter(function (tag) { + return tagWhitelist.indexOf(tag) !== -1; + }); + next(null, tags); + } + ], callback); + } + Topics.createEmptyTag = function (tag, callback) { if (!tag) { return callback(new Error('[[error:invalid-tag]]')); @@ -273,7 +297,7 @@ module.exports = function (Topics) { if (plugins.hasListeners('filter:topics.searchTags')) { plugins.fireHook('filter:topics.searchTags', {data: data}, next); } else { - findMatches(data.query, next); + findMatches(data.query, 0, next); } }, function (result, next) { @@ -295,7 +319,7 @@ module.exports = function (Topics) { if (plugins.hasListeners('filter:topics.autocompleteTags')) { plugins.fireHook('filter:topics.autocompleteTags', {data: data}, next); } else { - findMatches(data.query, next); + findMatches(data.query, data.cid, next); } }, function (result, next) { @@ -304,10 +328,21 @@ module.exports = function (Topics) { ], callback); }; - function findMatches(query, callback) { + function findMatches(query, cid, callback) { async.waterfall([ function (next) { - db.getSortedSetRevRange('tags:topic:count', 0, -1, next); + if (parseInt(cid, 10)) { + db.getSortedSetRange('cid:' + cid + ':tag:whitelist', 0, -1, next); + } else { + setImmediate(next, null, []); + } + }, + function (tagWhitelist, next) { + if (tagWhitelist.length) { + setImmediate(next, null, tagWhitelist); + } else { + db.getSortedSetRevRange('tags:topic:count', 0, -1, next); + } }, function (tags, next) { query = query.toLowerCase(); diff --git a/src/views/admin/manage/category.tpl b/src/views/admin/manage/category.tpl index 814b319913..7b2638b584 100644 --- a/src/views/admin/manage/category.tpl +++ b/src/views/admin/manage/category.tpl @@ -59,6 +59,10 @@ </div> </div> </fieldset> + <fieldset> + <label for="tag-whitelist">Tag Whitelist</label><br /> + <input id="tag-whitelist" type="text" class="form-control" placeholder="Enter category tags here" data-name="tagWhitelist" value="" /> + </fieldset> </div> </div> diff --git a/test/categories.js b/test/categories.js index 1a95846264..e0e035410b 100644 --- a/test/categories.js +++ b/test/categories.js @@ -541,7 +541,80 @@ describe('Categories', function () { }); }); }); + }); + + describe('tag whitelist', function () { + var cid; + var socketTopics = require('../src/socket.io/topics'); + before(function (done) { + Categories.create({ + name: 'test' + }, function (err, category) { + assert.ifError(err); + cid = category.cid; + done(); + }); + }); + + it('should error if data is invalid', function (done) { + socketTopics.isTagAllowed({uid: posterUid}, null, function (err) { + assert.equal(err.message, '[[error:invalid-data]]'); + done(); + }); + }); + + it('should return true if category whitelist is empty', function (done) { + socketTopics.isTagAllowed({uid: posterUid}, {tag: 'notallowed', cid: cid}, function (err, allowed) { + assert.ifError(err); + assert(allowed); + done(); + }); + }); + it('should add tags to category whitelist', function (done) { + var data = {}; + data[cid] = { + tagWhitelist: 'nodebb,jquery,javascript' + }; + Categories.update(data, function (err) { + assert.ifError(err); + db.getSortedSetRange('cid:' + cid + ':tag:whitelist', 0, -1, function (err, tagWhitelist) { + assert.ifError(err); + assert.deepEqual(['nodebb', 'jquery', 'javascript'], tagWhitelist); + done(); + }); + }); + }); + + it('should return false if category whitelist does not have tag', function (done) { + socketTopics.isTagAllowed({uid: posterUid}, {tag: 'notallowed', cid: cid}, function (err, allowed) { + assert.ifError(err); + assert(!allowed); + done(); + }); + }); + + it('should return true if category whitelist has tag', function (done) { + socketTopics.isTagAllowed({uid: posterUid}, {tag: 'nodebb', cid: cid}, function (err, allowed) { + assert.ifError(err); + assert(allowed); + done(); + }); + }); + + it('should post a topic with only allowed tags', function (done) { + Topics.post({ + uid: posterUid, + cid: cid, + title: 'Test Topic Title', + content: 'The content of test topic', + tags: ['nodebb', 'jquery', 'notallowed'] + }, function (err, data) { + assert.ifError(err); + assert.equal(data.topicData.tags.length, 2); + done(); + }); + }); });