diff --git a/core/acs.js b/core/acs.js new file mode 100644 index 00000000..54145bcb --- /dev/null +++ b/core/acs.js @@ -0,0 +1,53 @@ +/* jslint node: true */ +'use strict'; + +// ENiGMA½ +const checkAcs = require('./acs_parser.js').parse; + +// deps +const assert = require('assert'); +const _ = require('lodash'); + +class ACS { + constructor(client) { + this.client = client; + } + + check(acs, scope, defaultAcs) { + acs = acs ? acs[scope] : defaultAcs; + acs = acs || defaultAcs; + return checkAcs(acs, { client : this.client } ); + } + + hasMessageConfRead(conf) { + return this.check(conf.acs, 'read', ACS.Defaults.MessageConfRead); + } + + hasMessageAreaRead(area) { + return this.check(area.acs, 'read', ACS.Defaults.MessageAreaRead); + } + + getConditionalValue(condArray, memberName) { + assert(_.isArray(condArray)); + assert(_.isString(memberName)); + + const matchCond = condArray.find( cond => { + if(_.has(cond, 'acs')) { + return checkAcs(cond.acs, { client : this.client } ); + } else { + return true; // no acs check req. + } + }); + + if(matchCond) { + return matchCond[memberName]; + } + } +} + +ACS.Defaults = { + MessageAreaRead : 'GM[users]', + MessageConfRead : 'GM[users]', +}; + +module.exports = ACS; \ No newline at end of file diff --git a/core/client.js b/core/client.js index 25fd3874..ed7bbb3f 100644 --- a/core/client.js +++ b/core/client.js @@ -40,6 +40,7 @@ var moduleUtil = require('./module_util.js'); var menuUtil = require('./menu_util.js'); var Config = require('./config.js').config; var MenuStack = require('./menu_stack.js'); +const ACS = require('./acs.js'); var stream = require('stream'); var assert = require('assert'); @@ -54,7 +55,7 @@ exports.Client = Client; // * http://www.ansi-bbs.org/ansi-bbs-core-server.html // -// :TODO: put this in a common area!!!! +// :TODO: put this in a common area!!!!...actually, just replace it with more modern code - see ansi_term.js function getIntArgArray(array) { var i = array.length; while(i--) { @@ -68,19 +69,19 @@ var RE_DEV_ATTR_RESPONSE_ANYWHERE = /(?:\u001b\[)[\=\?]([0-9a-zA-Z\;]+)(c)/; var RE_META_KEYCODE_ANYWHERE = /(?:\u001b)([a-zA-Z0-9])/; var RE_META_KEYCODE = new RegExp('^' + RE_META_KEYCODE_ANYWHERE.source + '$'); var RE_FUNCTION_KEYCODE_ANYWHERE = new RegExp('(?:\u001b+)(O|N|\\[|\\[\\[)(?:' + [ - '(\\d+)(?:;(\\d+))?([~^$])', - '(?:M([@ #!a`])(.)(.))', // mouse stuff - '(?:1;)?(\\d+)?([a-zA-Z@])' - ].join('|') + ')'); + '(\\d+)(?:;(\\d+))?([~^$])', + '(?:M([@ #!a`])(.)(.))', // mouse stuff + '(?:1;)?(\\d+)?([a-zA-Z@])' +].join('|') + ')'); var RE_FUNCTION_KEYCODE = new RegExp('^' + RE_FUNCTION_KEYCODE_ANYWHERE.source); var RE_ESC_CODE_ANYWHERE = new RegExp( [ - RE_FUNCTION_KEYCODE_ANYWHERE.source, - RE_META_KEYCODE_ANYWHERE.source, - RE_DSR_RESPONSE_ANYWHERE.source, - RE_DEV_ATTR_RESPONSE_ANYWHERE.source, - /\u001b./.source - ].join('|')); + RE_FUNCTION_KEYCODE_ANYWHERE.source, + RE_META_KEYCODE_ANYWHERE.source, + RE_DSR_RESPONSE_ANYWHERE.source, + RE_DEV_ATTR_RESPONSE_ANYWHERE.source, + /\u001b./.source +].join('|')); function Client(input, output) { @@ -95,6 +96,7 @@ function Client(input, output) { this.currentTheme = { info : { name : 'N/A', description : 'None' } }; this.lastKeyPressMs = Date.now(); this.menuStack = new MenuStack(this); + this.acs = new ACS(this); Object.defineProperty(this, 'node', { get : function() { diff --git a/core/menu_stack.js b/core/menu_stack.js index 1cf5481f..454eb7da 100644 --- a/core/menu_stack.js +++ b/core/menu_stack.js @@ -3,7 +3,6 @@ // ENiGMA½ var loadMenu = require('./menu_util.js').loadMenu; -var acsUtil = require('./acs_util.js'); var _ = require('lodash'); var assert = require('assert'); @@ -49,7 +48,7 @@ MenuStack.prototype.next = function(cb) { var next; if(_.isArray(menuConfig.next)) { - next = acsUtil.getConditionalValue(this.client, menuConfig.next, 'next'); + next = this.client.acs.getConditionalValue(menuConfig.next, 'next'); if(!next) { cb(new Error('No matching condition for \'next\'!')); return; diff --git a/core/message_area.js b/core/message_area.js index 5d6b591f..74fbb8db 100644 --- a/core/message_area.js +++ b/core/message_area.js @@ -6,7 +6,6 @@ const msgDb = require('./database.js').dbs.message; const Config = require('./config.js').config; const Message = require('./message.js'); const Log = require('./logger.js').log; -const checkAcs = require('./acs_util.js').checkAcs; const msgNetRecord = require('./msg_network.js').recordMessage; // deps @@ -24,22 +23,15 @@ exports.getMessageConferenceByTag = getMessageConferenceByTag; exports.getMessageAreaByTag = getMessageAreaByTag; exports.changeMessageConference = changeMessageConference; exports.changeMessageArea = changeMessageArea; +exports.tempChangeMessageConfAndArea = tempChangeMessageConfAndArea; exports.getMessageListForArea = getMessageListForArea; +exports.getNewMessageCountInAreaForUser = getNewMessageCountInAreaForUser; exports.getNewMessagesInAreaForUser = getNewMessagesInAreaForUser; exports.getMessageAreaLastReadId = getMessageAreaLastReadId; exports.updateMessageAreaLastReadId = updateMessageAreaLastReadId; exports.persistMessage = persistMessage; exports.trimMessageAreasScheduledEvent = trimMessageAreasScheduledEvent; -const CONF_AREA_RW_ACS_DEFAULT = 'GM[users]'; -const AREA_MANAGE_ACS_DEFAULT = 'GM[sysops]'; - -const AREA_ACS_DEFAULT = { - read : CONF_AREA_RW_ACS_DEFAULT, - write : CONF_AREA_RW_ACS_DEFAULT, - manage : AREA_MANAGE_ACS_DEFAULT, -}; - // // Method for sorting Message areas and conferences // If the sort key is present and is a number, sort in numerical order; @@ -67,13 +59,12 @@ function getAvailableMessageConferences(client, options) { options = options || { includeSystemInternal : false }; // perform ACS check per conf & omit system_internal if desired - return _.omit(Config.messageConferences, (v, k) => { - if(!options.includeSystemInternal && 'system_internal' === k) { + return _.omit(Config.messageConferences, (conf, confTag) => { + if(!options.includeSystemInternal && 'system_internal' === confTag) { return true; } - const readAcs = v.acs || CONF_AREA_RW_ACS_DEFAULT; - return !checkAcs(client, readAcs); + return !client.acs.hasMessageConfRead(conf); }); } @@ -104,9 +95,8 @@ function getAvailableMessageAreasByConfTag(confTag, options) { return areas; } else { // perform ACS check per area - return _.omit(areas, v => { - const readAcs = _.has(v, 'acs.read') ? v.acs.read : CONF_AREA_RW_ACS_DEFAULT; - return !checkAcs(options.client, readAcs); + return _.omit(areas, area => { + return !options.client.acs.hasMessageAreaRead(area); }); } } @@ -140,16 +130,15 @@ function getDefaultMessageConferenceTag(client, disableAcsCheck) { // let defaultConf = _.findKey(Config.messageConferences, o => o.default); if(defaultConf) { - const acs = Config.messageConferences[defaultConf].acs || CONF_AREA_RW_ACS_DEFAULT; - if(true === disableAcsCheck || checkAcs(client, acs)) { + const conf = Config.messageConferences[defaultConf]; + if(true === disableAcsCheck || client.acs.hasMessageConfRead(conf)) { return defaultConf; } } // just use anything we can - defaultConf = _.findKey(Config.messageConferences, (o, k) => { - const acs = o.acs || CONF_AREA_RW_ACS_DEFAULT; - return 'system_internal' !== k && (true === disableAcsCheck || checkAcs(client, acs)); + defaultConf = _.findKey(Config.messageConferences, (conf, confTag) => { + return 'system_internal' !== confTag && (true === disableAcsCheck || client.acs.hasMessageConfRead(conf)); }); return defaultConf; @@ -169,15 +158,14 @@ function getDefaultMessageAreaTagByConfTag(client, confTag, disableAcsCheck) { const areaPool = Config.messageConferences[confTag].areas; let defaultArea = _.findKey(areaPool, o => o.default); if(defaultArea) { - const readAcs = _.has(areaPool, [ defaultArea, 'acs', 'read' ]) ? areaPool[defaultArea].acs.read : AREA_ACS_DEFAULT.read; - if(true === disableAcsCheck || checkAcs(client, readAcs)) { + const area = areaPool[defaultArea]; + if(true === disableAcsCheck || client.acs.hasMessageAreaRead(area)) { return defaultArea; } } - defaultArea = _.findKey(areaPool, (o, k) => { - const readAcs = _.has(areaPool, [ defaultArea, 'acs', 'read' ]) ? areaPool[defaultArea].acs.read : AREA_ACS_DEFAULT.read; - return (true === disableAcsCheck || checkAcs(client, readAcs)); + defaultArea = _.findKey(areaPool, (area) => { + return (true === disableAcsCheck || client.acs.hasMessageAreaRead(area)); }); return defaultArea; @@ -188,6 +176,25 @@ function getMessageConferenceByTag(confTag) { return Config.messageConferences[confTag]; } +function getMessageConfByAreaTag(areaTag) { + const confs = Config.messageConferences; + let conf; + _.forEach(confs, (v) => { + if(_.has(v, [ 'areas', areaTag ])) { + conf = v; + return false; // stop iteration + } + }); + return conf; +} + +function getMessageConfTagByAreaTag(areaTag) { + const confs = Config.messageConferences; + return Object.keys(confs).find( (confTag) => { + return _.has(confs, [ confTag, 'areas', areaTag]); + }); +} + function getMessageAreaByTag(areaTag, optionalConfTag) { const confs = Config.messageConferences; @@ -234,17 +241,10 @@ function changeMessageConference(client, confTag, cb) { } }, function validateAccess(conf, areaInfo, callback) { - const confAcs = conf.acs || CONF_AREA_RW_ACS_DEFAULT; - - if(!checkAcs(client, confAcs)) { - callback(new Error('User does not have access to this conference')); + if(!client.acs.hasMessageConfRead(conf) || !client.acs.hasMessageAreaRead(areaInfo.area)) { + return callback(new Error('Access denied to message area and/or conference')); } else { - const areaAcs = _.has(areaInfo, 'area.acs.read') ? areaInfo.area.acs.read : CONF_AREA_RW_ACS_DEFAULT; - if(!checkAcs(client, areaAcs)) { - callback(new Error('User does not have access to default area in this conference')); - } else { - callback(null, conf, areaInfo); - } + return callback(null, conf, areaInfo); } }, function changeConferenceAndArea(conf, areaInfo, callback) { @@ -268,34 +268,34 @@ function changeMessageConference(client, confTag, cb) { ); } -function changeMessageArea(client, areaTag, cb) { - +function changeMessageAreaWithOptions(client, areaTag, options, cb) { + options = options || {}; + async.waterfall( [ function getArea(callback) { const area = getMessageAreaByTag(areaTag); - - if(area) { - callback(null, area); - } else { - callback(new Error('Invalid message area tag')); - } + return callback(area ? null : new Error('Invalid message areaTag'), area); }, function validateAccess(area, callback) { // // Need at least *read* to access the area // - const readAcs = _.has(area, 'acs.read') ? area.acs.read : CONF_AREA_RW_ACS_DEFAULT; - if(!checkAcs(client, readAcs)) { - callback(new Error('User does not have access to this area')); + if(!client.acs.hasMessageAreaRead(area)) { + return callback(new Error('Access denied to message area')); } else { - callback(null, area); + return callback(null, area); } }, function changeArea(area, callback) { - client.user.persistProperty('message_area_tag', areaTag, function persisted(err) { - callback(err, area); - }); + if(true === options.persist) { + client.user.persistProperty('message_area_tag', areaTag, function persisted(err) { + return callback(err, area); + }); + } else { + client.user.properties['message_area_tag'] = areaTag; + return callback(null, area); + } } ], function complete(err, area) { @@ -310,6 +310,36 @@ function changeMessageArea(client, areaTag, cb) { ); } +// +// Temporairly -- e.g. non-persisted -- change to an area and it's +// associated underlying conference. ACS is checked for both. +// +// This is useful for example when doing a new scan +// +function tempChangeMessageConfAndArea(client, areaTag) { + const area = getMessageAreaByTag(areaTag); + const confTag = getMessageConfTagByAreaTag(areaTag); + + if(!area || !confTag) { + return false; + } + + const conf = getMessageConferenceByTag(confTag); + + if(!client.acs.hasMessageConfRead(conf) || !client.acs.hasMessageAreaRead(area)) { + return false; + } + + client.user.properties.message_conf_tag = confTag; + client.user.properties.message_area_tag = areaTag; + + return true; +} + +function changeMessageArea(client, areaTag, cb) { + changeMessageAreaWithOptions(client, areaTag, { persist : true }, cb); +} + function getMessageFromRow(row) { return { messageId : row.message_id, @@ -323,6 +353,62 @@ function getMessageFromRow(row) { }; } +function getNewMessageDataInAreaForUserSql(userId, areaTag, lastMessageId, what) { + // + // Helper for building SQL to fetch either a full message list or simply + // a count of new messages based on |what|. + // + // * If |areaTag| is Message.WellKnownAreaTags.Private, + // only messages addressed to |userId| should be returned/counted. + // + // * Only messages > |lastMessageId| should be returned/counted + // + const selectWhat = ('count' === what) ? + 'COUNT() AS count' : + 'message_id, message_uuid, reply_to_message_id, to_user_name, from_user_name, subject, modified_timestamp, view_count'; + + let sql = + `SELECT ${selectWhat} + FROM message + WHERE area_tag = "${areaTag}" AND message_id > ${lastMessageId}`; + + if(Message.WellKnownAreaTags.Private === areaTag) { + sql += + ` AND message_id in ( + SELECT message_id + FROM message_meta + WHERE meta_category = "System" AND meta_name = "${Message.SystemMetaNames.LocalToUserID}" AND meta_value = ${userId} + )`; + } + + if('count' === what) { + sql += ';'; + } else { + sql += ' ORDER BY message_id;'; + } + + return sql; +} + +function getNewMessageCountInAreaForUser(userId, areaTag, cb) { + async.waterfall( + [ + function getLastMessageId(callback) { + getMessageAreaLastReadId(userId, areaTag, function fetched(err, lastMessageId) { + callback(null, lastMessageId || 0); // note: willingly ignoring any errors here! + }); + }, + function getCount(lastMessageId, callback) { + const sql = getNewMessageDataInAreaForUserSql(userId, areaTag, lastMessageId, 'count'); + msgDb.get(sql, (err, row) => { + return callback(err, row ? row.count : 0); + }); + } + ], + cb + ); +} + function getNewMessagesInAreaForUser(userId, areaTag, cb) { // // If |areaTag| is Message.WellKnownAreaTags.Private, @@ -340,6 +426,7 @@ function getNewMessagesInAreaForUser(userId, areaTag, cb) { }); }, function getMessages(lastMessageId, callback) { + /* let sql = `SELECT message_id, message_uuid, reply_to_message_id, to_user_name, from_user_name, subject, modified_timestamp, view_count FROM message @@ -353,6 +440,8 @@ function getNewMessagesInAreaForUser(userId, areaTag, cb) { } sql += ' ORDER BY message_id;'; + */ + const sql = getNewMessageDataInAreaForUserSql(userId, areaTag, lastMessageId, 'messages'); msgDb.each(sql, function msgRow(err, row) { if(!err) { diff --git a/core/new_scan.js b/core/new_scan.js index 37dea421..d7825940 100644 --- a/core/new_scan.js +++ b/core/new_scan.js @@ -138,63 +138,35 @@ function NewScanModule(options) { // Advance to next area if possible if(sortedAreas.length >= self.currentScanAux.area + 1) { self.currentScanAux.area += 1; - callback(null); + return callback(null); } else { self.updateScanStatus(self.scanCompleteMsg); - callback(new Error('No more areas')); + return callback(new Error('No more areas')); // this will stop our scan } }, function updateStatusScanStarted(callback) { self.updateScanStatus(self.scanStartFmt.format(getFormatObj())); - callback(null); + return callback(null); }, - function newScanAreaAndGetMessages(callback) { - msgArea.getNewMessagesInAreaForUser( - self.client.user.userId, currentArea.areaTag, function msgs(err, msgList) { - if(!err) { - if(0 === msgList.length) { - self.updateScanStatus(self.scanFinishNoneFmt.format(getFormatObj())); - } else { - const formatObj = Object.assign(getFormatObj(), { count : msgList.length } ); - self.updateScanStatus(self.scanFinishNewFmt.format(formatObj)); - } - } - callback(err, msgList); + function getNewMessagesCountInArea(callback) { + msgArea.getNewMessageCountInAreaForUser( + self.client.user.userId, currentArea.areaTag, (err, newMessageCount) => { + callback(err, newMessageCount); } ); }, - function displayMessageList(msgList) { - if(msgList && msgList.length > 0) { - const nextModuleOpts = { - extraArgs: { - messageAreaTag : currentArea.areaTag, - messageList : msgList, - } - }; - - // - // provide a serializer so we don't dump *huge* bits of information to the log - // due to the size of |messageList| - // https://github.com/trentm/node-bunyan/issues/189 - // - nextModuleOpts.extraArgs.toJSON = function() { - let logMsgList; - if(this.messageList.length <= 4) { - logMsgList = this.messageList; - } else { - logMsgList = this.messageList.slice(0, 2).concat(this.messageList.slice(-2)); - } - - return { - messageAreaTag : this.messageAreaTag, - partialMessageList : logMsgList, - }; - }; - - self.gotoMenu(config.newScanMessageList || 'newScanMessageList', nextModuleOpts); - } else { - self.newScanMessageArea(conf, cb); + function displayMessageList(newMessageCount) { + if(newMessageCount <= 0) { + return self.newScanMessageArea(conf, cb); // next area, if any } + + const nextModuleOpts = { + extraArgs: { + messageAreaTag : currentArea.areaTag, + } + }; + + return self.gotoMenu(config.newScanMessageList || 'newScanMessageList', nextModuleOpts); } ], cb // no more areas diff --git a/mods/msg_list.js b/mods/msg_list.js index 1b0b8bf3..cdb571f0 100644 --- a/mods/msg_list.js +++ b/mods/msg_list.js @@ -82,6 +82,23 @@ function MessageListModule(options) { } }; + // + // Provide a serializer so we don't dump *huge* bits of information to the log + // due to the size of |messageList|. See https://github.com/trentm/node-bunyan/issues/189 + // + modOpts.extraArgs.toJSON = function() { + const logMsgList = (this.messageList.length <= 4) ? + this.messageList : + this.messageList.slice(0, 2).concat(this.messageList.slice(-2)); + + return { + messageAreaTag : this.messageAreaTag, + apprevMessageList : logMsgList, + messageCount : this.messageList.length, + messageIndex : formData.value.message, + }; + }; + self.gotoMenu(config.menuViewPost || 'messageAreaViewPost', modOpts); } } @@ -104,11 +121,28 @@ MessageListModule.prototype.enter = function() { // Config can specify |messageAreaTag| else it comes from // the user's current area // - if(!this.messageAreaTag) { - this.messageAreaTag = this.client.user.properties.message_area_tag; + if(this.messageAreaTag) { + this.prevMessageConfAndArea = { + confTag : this.client.user.properties.message_conf_tag, + areaTag : this.client.user.properties.message_area_tag, + }; + if(!messageArea.tempChangeMessageConfAndArea(this.client, this.messageAreaTag)) { + // :TODO: Really, checks should have been done & failed before this, but log here! + } + } else { + this.messageAreaTag = this.messageAreaTag = this.client.user.properties.message_area_tag; } }; +MessageListModule.prototype.leave = function() { + if(this.prevMessageConfAndArea) { + this.client.user.properties.message_conf_tag = this.prevMessageConfAndArea.confTag; + this.client.user.properties.message_area_tag = this.prevMessageConfAndArea.areaTag; + } + + MessageListModule.super_.prototype.leave.call(this); +}; + MessageListModule.prototype.mciReady = function(mciData, cb) { const self = this; const vc = self.viewControllers.allViews = new ViewController( { client : self.client } );