diff --git a/UPGRADE.md b/UPGRADE.md index 8cb98e13..359abec1 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -61,6 +61,7 @@ webSocket: { * The `system.db` `user_event_log` table has been updated to include a unique session ID. Previously this table was not used, but you will need to perform a slight maintenance task before it can be properly used. After updating to `0.0.9-alpha`, please run the following: `sqlite3 db/system.db DROP TABLE user_event_log;`. The new table format will be created and used at startup. * If you have art configured for message conference or area selection via the `art` configuration value, you will need to include a `show_art` menu reference. Defaulted to `changeMessageConfPreArt` for conferences and `changeMessageAreaPreArt` for areas & included in the example `menu.hjson`. * Config `defaults` section was theme related and as such, has been renamed to `theme`. `defaults.theme` is now `theme.default`, and `preLoginTheme` is now `theme.preLogin`. See `config.js` if this isn't clear as mud. +* Similar to the last item, `defaults.general.passwordChar` in `theme.hjson` is now just `defaults.passwordChar`. # 0.0.7-alpha to 0.0.8-alpha diff --git a/WHATSNEW.md b/WHATSNEW.md index 90b9e374..191f1c7b 100644 --- a/WHATSNEW.md +++ b/WHATSNEW.md @@ -21,7 +21,7 @@ This document attempts to track **major** changes and additions in ENiGMA½. For * New MCI codes including general purpose movement codes. See [MCI codes](docs/art/mci.md) * `install.sh` will now attempt to use NPM's `--build-from-source` option when ARM is detected. * `oputil.js config new` will now generate a much more complete configuration file with comments, examples, etc. `oputil.js config cat` dumps your current config to stdout. - +* Handling of failed login attempts is now fully in. Disconnect clients, lock out accounts, ability to auto or unlock at (email-driven) password reset, etc. See `users.failedLogin` in `config.hjson`. ## 0.0.8-alpha diff --git a/art/themes/luciano_blocktronics/ACCOUNTINACTIVE.ANS b/art/themes/luciano_blocktronics/ACCOUNTINACTIVE.ANS new file mode 100644 index 00000000..d0d2cd0e Binary files /dev/null and b/art/themes/luciano_blocktronics/ACCOUNTINACTIVE.ANS differ diff --git a/art/themes/luciano_blocktronics/ACCOUNTLOCKED.ANS b/art/themes/luciano_blocktronics/ACCOUNTLOCKED.ANS new file mode 100644 index 00000000..b743a3cf Binary files /dev/null and b/art/themes/luciano_blocktronics/ACCOUNTLOCKED.ANS differ diff --git a/art/themes/luciano_blocktronics/SETMNSDATE.ANS b/art/themes/luciano_blocktronics/SETMNSDATE.ANS index 4d3b43a3..61cbb3da 100644 Binary files a/art/themes/luciano_blocktronics/SETMNSDATE.ANS and b/art/themes/luciano_blocktronics/SETMNSDATE.ANS differ diff --git a/art/themes/luciano_blocktronics/theme.hjson b/art/themes/luciano_blocktronics/theme.hjson index f359539f..f0ad7540 100644 --- a/art/themes/luciano_blocktronics/theme.hjson +++ b/art/themes/luciano_blocktronics/theme.hjson @@ -9,9 +9,7 @@ customization: { defaults: { - general: { - passwordChar: * - } + passwordChar: * dateTimeFormat: { short: MMM Do h:mm a @@ -256,6 +254,16 @@ } } + messageAreaSetNewScanDate: { + mci: { + SM2: { + width: 54 + itemFormat: "|00|07{conf.name} |08- |07{area.name}" + focusItemFormat: "|00|15{conf.name} |07- |15{area.name}" + } + } + } + mailMenuCreateMessage: { 0: { mci: { @@ -800,16 +808,13 @@ } fileBaseDownloadManager: { - config: { - queueListFormat: "|00|03{fileName:<61.60} {byteSize!sizeWithoutAbbr:>7.6} |11{byteSize!sizeAbbr}" - focusQueueListFormat: "|00|19|15{fileName:<61.60} {byteSize!sizeWithoutAbbr:>7.6} {byteSize!sizeAbbr}" - } - 0: { mci: { VM1: { height: 11 width: 69 + itemFormat: "|00|03{fileName:<61.60} {byteSize!sizeWithoutAbbr:>7.6} |11{byteSize!sizeAbbr}" + focusItemFormat: "|00|19|15{fileName:<61.60} {byteSize!sizeWithoutAbbr:>7.6} {byteSize!sizeAbbr}" } HM2: { width: 50 @@ -821,8 +826,6 @@ fileBaseWebDownloadManager: { config: { - queueListFormat: "|00|03{webDlLink:<36.35} {fileName:<26.25} {byteSize!sizeWithoutAbbr:>7.6} |11{byteSize!sizeAbbr}" - focusQueueListFormat: "|00|19|15{webDlLink:<36.35} {fileName:<26.25} {byteSize!sizeWithoutAbbr:>7.6} {byteSize!sizeAbbr}" queueManagerInfoFormat10: "|03batch|08: |03{webBatchDlLink}" queueManagerInfoFormat11: "|03exp |08: |03{webBatchDlExpire}" } @@ -831,6 +834,8 @@ mci: { VM1: { height: 8 + itemFormat: "|00|03{webDlLink:<36.35} {fileName:<26.25} {byteSize!sizeWithoutAbbr:>7.6} |11{byteSize!sizeAbbr}" + focusItemFormat: "|00|19|15{webDlLink:<36.35} {fileName:<26.25} {byteSize!sizeWithoutAbbr:>7.6} {byteSize!sizeAbbr}" } HM2: { width: 50 diff --git a/core/acs_parser.js b/core/acs_parser.js index 3025f654..6e32cb06 100644 --- a/core/acs_parser.js +++ b/core/acs_parser.js @@ -847,6 +847,8 @@ function peg$parse(input, options) { const client = options.client; const user = options.client.user; + const UserProps = require('./user_property.js'); + const moment = require('moment'); function checkAccess(acsCode, value) { @@ -863,7 +865,7 @@ function peg$parse(input, options) { value = [ value ]; } - const userAccountStatus = parseInt(user.properties.account_status, 10); + const userAccountStatus = user.getPropertyAsNumber(UserProps.AccountStatus); return value.map(n => parseInt(n, 10)).includes(userAccountStatus); }, EC : function isEncoding() { @@ -888,15 +890,15 @@ function peg$parse(input, options) { return value.map(n => parseInt(n, 10)).includes(client.node); }, NP : function numberOfPosts() { - const postCount = parseInt(user.properties.post_count, 10) || 0; + const postCount = user.getPropertyAsNumber(UserProps.PostCount) || 0; return !isNaN(value) && postCount >= value; }, NC : function numberOfCalls() { - const loginCount = parseInt(user.properties.login_count, 10); + const loginCount = user.getPropertyAsNumber(UserProps.LoginCount); return !isNaN(value) && loginCount >= value; }, AA : function accountAge() { - const accountCreated = moment(user.properties.account_created); + const accountCreated = moment(user.getProperty(UserProps.AccountCreated)); const now = moment(); const daysOld = accountCreated.diff(moment(), 'days'); return !isNaN(value) && @@ -905,36 +907,36 @@ function peg$parse(input, options) { daysOld >= value; }, BU : function bytesUploaded() { - const bytesUp = parseInt(user.properties.ul_total_bytes, 10) || 0; + const bytesUp = user.getPropertyAsNumber(UserProps.FileUlTotalBytes) || 0; return !isNaN(value) && bytesUp >= value; }, UP : function uploads() { - const uls = parseInt(user.properties.ul_total_count, 10) || 0; + const uls = user.getPropertyAsNumber(UserProps.FileUlTotalCount) || 0; return !isNaN(value) && uls >= value; }, BD : function bytesDownloaded() { - const bytesDown = parseInt(user.properties.dl_total_bytes, 10) || 0; + const bytesDown = user.getPropertyAsNumber(UserProps.FileDlTotalBytes) || 0; return !isNaN(value) && bytesDown >= value; }, DL : function downloads() { - const dls = parseInt(user.properties.dl_total_count, 10) || 0; + const dls = user.getPropertyAsNumber(UserProps.FileDlTotalCount) || 0; return !isNaN(value) && dls >= value; }, NR : function uploadDownloadRatioGreaterThan() { - const ulCount = parseInt(user.properties.ul_total_count, 10) || 0; - const dlCount = parseInt(user.properties.dl_total_count, 10) || 0; + const ulCount = user.getPropertyAsNumber(UserProps.FileUlTotalCount) || 0; + const dlCount = user.getPropertyAsNumber(UserProps.FileDlTotalCount) || 0; const ratio = ~~((ulCount / dlCount) * 100); return !isNaN(value) && ratio >= value; }, KR : function uploadDownloadByteRatioGreaterThan() { - const ulBytes = parseInt(user.properties.ul_total_bytes, 10) || 0; - const dlBytes = parseInt(user.properties.dl_total_bytes, 10) || 0; + const ulBytes = user.getPropertyAsNumber(UserProps.FileUlTotalBytes) || 0; + const dlBytes = user.getPropertyAsNumber(UserProps.FileDlTotalBytes) || 0; const ratio = ~~((ulBytes / dlBytes) * 100); return !isNaN(value) && ratio >= value; }, PC : function postCallRatio() { - const postCount = parseInt(user.properties.post_count, 10) || 0; - const loginCount = parseInt(user.properties.login_count, 10); + const postCount = user.getPropertyAsNumber(UserProps.PostCount) || 0; + const loginCount = user.getPropertyAsNumber(UserProps.LoginCount) || 0; const ratio = ~~((postCount / loginCount) * 100); return !isNaN(value) && ratio >= value; }, diff --git a/core/bbs.js b/core/bbs.js index b4f88296..76b7138b 100644 --- a/core/bbs.js +++ b/core/bbs.js @@ -10,6 +10,7 @@ const conf = require('./config.js'); const logger = require('./logger.js'); const database = require('./database.js'); const resolvePath = require('./misc_util.js').resolvePath; +const UserProps = require('./user_property.js'); // deps const async = require('async'); @@ -229,18 +230,21 @@ function initialize(cb) { }, function getOpProps(opUserName, next) { const propLoadOpts = { - names : [ 'real_name', 'sex', 'email_address', 'location', 'affiliation' ], + names : [ + UserProps.RealName, UserProps.Sex, UserProps.EmailAddress, + UserProps.Location, UserProps.Affiliations, + ], }; User.loadProperties(User.RootUserID, propLoadOpts, (err, opProps) => { - return next(err, opUserName, opProps); + return next(err, opUserName, opProps, propLoadOpts); }); } ], - (err, opUserName, opProps) => { + (err, opUserName, opProps, propLoadOpts) => { const StatLog = require('./stat_log.js'); if(err) { - [ 'username', 'real_name', 'sex', 'email_address', 'location', 'affiliation' ].forEach(v => { + propLoadOpts.concat('username').forEach(v => { StatLog.setNonPeristentSystemStat(`sysop_${v}`, 'N/A'); }); } else { diff --git a/core/client_connections.js b/core/client_connections.js index 6b8faf65..558d0a0f 100644 --- a/core/client_connections.js +++ b/core/client_connections.js @@ -2,13 +2,14 @@ 'use strict'; // ENiGMA½ -const logger = require('./logger.js'); -const Events = require('./events.js'); +const logger = require('./logger.js'); +const Events = require('./events.js'); +const UserProps = require('./user_property.js'); // deps -const _ = require('lodash'); -const moment = require('moment'); -const hashids = require('hashids'); +const _ = require('lodash'); +const moment = require('moment'); +const hashids = require('hashids'); exports.getActiveConnections = getActiveConnections; exports.getActiveConnectionList = getActiveConnectionList; @@ -47,11 +48,11 @@ function getActiveConnectionList(authUsersOnly) { // if(ac.user.isAuthenticated()) { entry.userName = ac.user.username; - entry.realName = ac.user.properties.real_name; - entry.location = ac.user.properties.location; - entry.affils = entry.affiliation = ac.user.properties.affiliation; + entry.realName = ac.user.properties[UserProps.RealName]; + entry.location = ac.user.properties[UserProps.Location]; + entry.affils = entry.affiliation = ac.user.properties[UserProps.Affiliations]; - const diff = now.diff(moment(ac.user.properties.last_login_timestamp), 'minutes'); + const diff = now.diff(moment(ac.user.properties[UserProps.LastLoginTs]), 'minutes'); entry.timeOn = moment.duration(diff, 'minutes'); } return entry; @@ -62,7 +63,7 @@ function addNewClient(client, clientSock) { const id = client.session.id = clientConnections.push(client) - 1; const remoteAddress = client.remoteAddress = clientSock.remoteAddress; - // create a uniqe identifier one-time ID for this session + // create a unique identifier one-time ID for this session client.session.uniqueId = new hashids('ENiGMA½ClientSession').encode([ id, moment().valueOf() ]); // Create a client specific logger diff --git a/core/color_codes.js b/core/color_codes.js index 2032f057..e07b805e 100644 --- a/core/color_codes.js +++ b/core/color_codes.js @@ -110,7 +110,8 @@ function renegadeToAnsi(s, client) { result += s.substr(lastIndex, m.index - lastIndex) + attr; } else if(m[4] || m[1]) { // |AA MCI code or |Cx## movement where ## is in m[1] - const val = getPredefinedMCIValue(client, m[4] || m[1], m[2]) || (m[0]); // value itself or literal + let val = getPredefinedMCIValue(client, m[4] || m[1], m[2]); + val = _.isString(val) ? val : m[0]; // value itself or literal result += s.substr(lastIndex, m.index - lastIndex) + val; } else if(m[5]) { // || -- literal '|', that is. diff --git a/core/config.js b/core/config.js index 97091be5..701c8494 100644 --- a/core/config.js +++ b/core/config.js @@ -42,17 +42,53 @@ function hasMessageConferenceAndArea(config) { return result; } +const ArrayReplaceKeyPaths = [ + 'loginServers.ssh.algorithms.kex', + 'loginServers.ssh.algorithms.cipher', + 'loginServers.ssh.algorithms.hmac', + 'loginServers.ssh.algorithms.compress', +]; + +const ArrayReplaceKeys = [ + 'args', + 'sendArgs', 'recvArgs', 'recvArgsNonBatch', +]; + function mergeValidateAndFinalize(config, cb) { + const defaultConfig = getDefaultConfig(); + + const arrayReplaceKeyPathsMutable = _.clone(ArrayReplaceKeyPaths); + const shouldReplaceArray = (arr, key) => { + if(ArrayReplaceKeys.includes(key)) { + return true; + } + for(let i = 0; i < arrayReplaceKeyPathsMutable.length; ++i) { + const o = _.get(defaultConfig, arrayReplaceKeyPathsMutable[i]); + if(_.isEqual(o, arr)) { + arrayReplaceKeyPathsMutable.splice(i, 1); + return true; + } + } + return false; + }; + async.waterfall( [ function mergeWithDefaultConfig(callback) { const mergedConfig = _.mergeWith( - getDefaultConfig(), - config, (conf1, conf2) => { - // Arrays should always concat - if(_.isArray(conf1)) { - // :TODO: look for collisions & override dupes - return conf1.concat(conf2); + defaultConfig, + config, + (defConfig, userConfig, key) => { + if(Array.isArray(defConfig) && Array.isArray(userConfig)) { + // + // Arrays are special: Some we merge, while others + // we simply replace. + // + if(shouldReplaceArray(defConfig, key)) { + return userConfig; + } else { + return _.uniq(defConfig.concat(userConfig)); + } } } ); @@ -136,7 +172,6 @@ function getDefaultConfig() { // :TODO: closedSystem and loginAttemps prob belong under users{}? closedSystem : false, // is the system closed to new users? - loginAttempts : 3, menuFile : 'menu.hjson', // 'oputil.js config new' will set this appropriately in config.hjson; may be full path promptFile : 'prompt.hjson', // 'oputil.js config new' will set this appropriately in config.hjson; may be full path @@ -181,6 +216,13 @@ function getDefaultConfig() { preAuthIdleLogoutSeconds : 60 * 3, // 3m idleLogoutSeconds : 60 * 6, // 6m + + failedLogin : { + disconnect : 3, // 0=disabled + lockAccount : 9, // 0=disabled; Mark user status as "locked" if >= N + autoUnlockMinutes : 60 * 6, // 0=disabled; Auto unlock after N minutes. + }, + unlockAtEmailPwReset : true, // if true, password reset via email will unlock locked accounts }, theme : { diff --git a/core/database.js b/core/database.js index 8bae6a87..017aa949 100644 --- a/core/database.js +++ b/core/database.js @@ -67,7 +67,13 @@ function loadDatabaseForMod(modInfo, cb) { function getISOTimestampString(ts) { ts = ts || moment(); - return ts.format('YYYY-MM-DDTHH:mm:ss.SSSZ'); + if(!moment.isMoment(ts)) { + if(_.isString(ts)) { + ts = ts.replace(/\//g, '-'); + } + ts = moment(ts); + } + return ts.utc().format('YYYY-MM-DDTHH:mm:ss.SSS[Z]'); } function sanatizeString(s) { diff --git a/core/download_queue.js b/core/download_queue.js index 0d1e3847..28ca3aac 100644 --- a/core/download_queue.js +++ b/core/download_queue.js @@ -2,6 +2,7 @@ 'use strict'; const FileEntry = require('./file_entry.js'); +const UserProps = require('./user_property.js'); // deps const { partition } = require('lodash'); @@ -11,8 +12,8 @@ module.exports = class DownloadQueue { this.client = client; if(!Array.isArray(this.client.user.downloadQueue)) { - if(this.client.user.properties.dl_queue) { - this.loadFromProperty(this.client.user.properties.dl_queue); + if(this.client.user.properties[UserProps.DownloadQueue]) { + this.loadFromProperty(this.client.user.properties[UserProps.DownloadQueue]); } else { this.client.user.downloadQueue = []; } diff --git a/core/dropfile.js b/core/dropfile.js index 345a031a..a23c5c1c 100644 --- a/core/dropfile.js +++ b/core/dropfile.js @@ -4,6 +4,7 @@ // ENiGMA½ const Config = require('./config.js').get; const StatLog = require('./stat_log.js'); +const UserProps = require('./user_property.js'); // deps const fs = require('graceful-fs'); @@ -84,6 +85,8 @@ module.exports = class DropFile { const prop = this.client.user.properties; const now = moment(); const secLevel = this.client.user.getLegacySecurityLevel().toString(); + const fullName = prop[UserProps.RealName] || this.client.user.username; + const bd = moment(prop[UserProp.Birthdate).format('MM/DD/YY'); // :TODO: fix time remaining // :TODO: fix default protocol -- user prop: transfer_protocol @@ -97,13 +100,13 @@ module.exports = class DropFile { 'Y', // "Printer Toggle - Y=On N=Off (Default to Y)" 'Y', // "Page Bell - Y=On N=Off (Default to Y)" 'Y', // "Caller Alarm - Y=On N=Off (Default to Y)" - prop.real_name || this.client.user.username, // "User Full Name" - prop.location || 'Anywhere', // "Calling From" + fullName, // "User Full Name" + prop[UserProps.Location]|| 'Anywhere', // "Calling From" '123-456-7890', // "Home Phone" '123-456-7890', // "Work/Data Phone" 'NOPE', // "Password" (Note: this is never given out or even stored plaintext) secLevel, // "Security Level" - prop.login_count.toString(), // "Total Times On" + prop[UserProps.LoginCount].toString(), // "Total Times On" now.format('MM/DD/YY'), // "Last Date Called" '15360', // "Seconds Remaining THIS call (for those that particular)" '256', // "Minutes Remaining THIS call" @@ -120,7 +123,7 @@ module.exports = class DropFile { '0', // "Total Downloads" '0', // "Daily Download "K" Total" '999999', // "Daily Download Max. "K" Limit" - moment(prop.birthdate).format('MM/DD/YY'), // "Caller's Birthdate" + bd, // "Caller's Birthdate" 'X:\\MAIN\\', // "Path to the MAIN directory (where User File is)" 'X:\\GEN\\', // "Path to the GEN directory" StatLog.getSystemStat('sysop_username'), // "Sysop's Name (name BBS refers to Sysop as)" @@ -141,7 +144,7 @@ module.exports = class DropFile { '0', // "Files d/led so far today" '0', // "Total "K" Bytes Uploaded" '0', // "Total "K" Bytes Downloaded" - prop.user_comment || 'None', // "User Comment" + prop[UserProps.UserComment] || 'None', // "User Comment" '0', // "Total Doors Opened" '0', // "Total Messages Left" @@ -168,7 +171,7 @@ module.exports = class DropFile { '115200', Config().general.boardName, this.client.user.userId.toString(), - this.client.user.properties.real_name || this.client.user.username, + this.client.user.properties[UserProps.RealName] || this.client.user.username, this.client.user.username, this.client.user.getLegacySecurityLevel().toString(), '546', // :TODO: Minutes left! @@ -189,21 +192,22 @@ module.exports = class DropFile { const opUserName = /[^\s]*/.exec(StatLog.getSystemStat('sysop_username'))[0]; const userName = /[^\s]*/.exec(this.client.user.username)[0]; const secLevel = this.client.user.getLegacySecurityLevel().toString(); + const location = this.client.user.properties[UserProps.Location]; return iconv.encode( [ - Config().general.boardName, // "The name of the system." - opUserName, // "The sysop's name up to the first space." - opUserName, // "The sysop's name following the first space." - 'COM1', // "The serial port the modem is connected to, or 0 if logged in on console." - '57600', // "The current port (DTE) rate." - '0', // "The number "0"" - userName, // "The current user's name, up to the first space." - userName, // "The current user's name, following the first space." - this.client.user.properties.location || '', // "Where the user lives, or a blank line if unknown." - '1', // "The number "0" if TTY, or "1" if ANSI." - secLevel, // "The number 5 for problem users, 30 for regular users, 80 for Aides, and 100 for Sysops." - '546', // "The number of minutes left in the current user's account, limited to 546 to keep from overflowing other software." - '-1' // "The number "-1" if using an external serial driver or "0" if using internal serial routines." + Config().general.boardName, // "The name of the system." + opUserName, // "The sysop's name up to the first space." + opUserName, // "The sysop's name following the first space." + 'COM1', // "The serial port the modem is connected to, or 0 if logged in on console." + '57600', // "The current port (DTE) rate." + '0', // "The number "0"" + userName, // "The current user's name, up to the first space." + userName, // "The current user's name, following the first space." + location || '', // "Where the user lives, or a blank line if unknown." + '1', // "The number "0" if TTY, or "1" if ANSI." + secLevel, // "The number 5 for problem users, 30 for regular users, 80 for Aides, and 100 for Sysops." + '546', // "The number of minutes left in the current user's account, limited to 546 to keep from overflowing other software." + '-1' // "The number "-1" if using an external serial driver or "0" if using internal serial routines." ].join('\r\n') + '\r\n', 'cp437'); } diff --git a/core/enig_error.js b/core/enig_error.js index 3f189dd7..78798a4a 100644 --- a/core/enig_error.js +++ b/core/enig_error.js @@ -34,8 +34,9 @@ exports.Errors = { ExternalProcess : (reason, reasonCode) => new EnigError('External process error', -32005, reason, reasonCode), MissingConfig : (reason, reasonCode) => new EnigError('Missing configuration', -32006, reason, reasonCode), UnexpectedState : (reason, reasonCode) => new EnigError('Unexpected state', -32007, reason, reasonCode), - MissingParam : (reason, reasonCode) => new EnigError('Missing paramater(s)', -32008, reason, reasonCode), + MissingParam : (reason, reasonCode) => new EnigError('Missing paramter(s)', -32008, reason, reasonCode), MissingMci : (reason, reasonCode) => new EnigError('Missing required MCI code(s)', -32009, reason, reasonCode), + BadLogin : (reason, reasonCode) => new EnigError('Bad login attempt', -32010, reason, reasonCode), }; exports.ErrorReasons = { @@ -44,4 +45,9 @@ exports.ErrorReasons = { NoPreviousMenu : 'NOPREV', NoConditionMatch : 'NOCONDMATCH', NotEnabled : 'NOTENABLED', -}; \ No newline at end of file + AlreadyLoggedIn : 'ALREADYLOGGEDIN', + TooMany : 'TOOMANY', + Disabled : 'DISABLED', + Inactive : 'INACTIVE', + Locked : 'LOCKED', +}; diff --git a/core/event_scheduler.js b/core/event_scheduler.js index 22c2d58d..95de5a97 100644 --- a/core/event_scheduler.js +++ b/core/event_scheduler.js @@ -5,6 +5,7 @@ const PluginModule = require('./plugin_module.js').PluginModule; const Config = require('./config.js').get; const Log = require('./logger.js').log; +const { Errors } = require('./enig_error.js'); const _ = require('lodash'); const later = require('later'); @@ -116,7 +117,7 @@ class ScheduledEvent { methodModule[this.action.what](this.action.args, err => { if(err) { Log.debug( - { error : err.toString(), eventName : this.name, action : this.action }, + { error : err.message, eventName : this.name, action : this.action }, 'Error performing scheduled event action'); } @@ -124,7 +125,7 @@ class ScheduledEvent { }); } catch(e) { Log.warn( - { error : e.toString(), eventName : this.name, action : this.action }, + { error : e.message, eventName : this.name, action : this.action }, 'Failed to perform scheduled event action'); return cb(e); @@ -138,7 +139,22 @@ class ScheduledEvent { env : process.env, }; - const proc = pty.spawn(this.action.what, this.action.args, opts); + let proc; + try { + proc = pty.spawn(this.action.what, this.action.args, opts); + } catch(e) { + Log.warn( + { + error : 'Failed to spawn @execute process', + reason : e.message, + eventName : this.name, + action : this.action, + what : this.action.what, + args : this.action.args + } + ); + return cb(e); + } proc.once('exit', exitCode => { if(exitCode) { diff --git a/core/file_area_filter_edit.js b/core/file_area_filter_edit.js index 06c0fd6e..e20d766b 100644 --- a/core/file_area_filter_edit.js +++ b/core/file_area_filter_edit.js @@ -7,6 +7,7 @@ const ViewController = require('./view_controller.js').ViewContro const getSortedAvailableFileAreas = require('./file_base_area.js').getSortedAvailableFileAreas; const FileBaseFilters = require('./file_base_filter.js'); const stringFormat = require('./string_format.js'); +const UserProps = require('./user_property.js'); // deps const async = require('async'); @@ -111,7 +112,7 @@ exports.getModule = class FileAreaFilterEdit extends MenuModule { // // If the item was also the active filter, we need to make a new one active // - if(filterUuid === this.client.user.properties.file_base_filter_active_uuid) { + if(filterUuid === this.client.user.properties[UserProps.FileBaseFilterActiveUuid]) { const newActive = this.filtersArray[this.currentFilterIndex]; if(newActive) { filters.setActive(newActive.uuid); diff --git a/core/file_base_area.js b/core/file_base_area.js index 78450501..760eb87c 100644 --- a/core/file_base_area.js +++ b/core/file_base_area.js @@ -14,6 +14,7 @@ const resolveMimeType = require('./mime_util.js').resolveMimeType; const stringFormat = require('./string_format.js'); const wordWrapText = require('./word_wrap.js').wordWrapText; const StatLog = require('./stat_log.js'); +const UserProps = require('./user_property.js'); // deps const _ = require('lodash'); @@ -136,11 +137,11 @@ function changeFileAreaWithOptions(client, areaTag, options, cb) { }, function changeArea(area, callback) { if(true === options.persist) { - client.user.persistProperty('file_area_tag', areaTag, err => { + client.user.persistProperty(UserProps.FileAreaTag, areaTag, err => { return callback(err, area); }); } else { - client.user.properties['file_area_tag'] = areaTag; + client.user.properties[UserProps.FileAreaTag] = areaTag; return callback(null, area); } } @@ -705,7 +706,7 @@ function scanFile(filePath, options, iterator, cb) { // up to many seconds in time for larger files. // const chunkSize = 1024 * 64; - const buffer = new Buffer(chunkSize); + const buffer = Buffer.allocUnsafe(chunkSize); fs.open(filePath, 'r', (err, fd) => { if(err) { diff --git a/core/file_base_download_manager.js b/core/file_base_download_manager.js index f590ebd3..50abd6da 100644 --- a/core/file_base_download_manager.js +++ b/core/file_base_download_manager.js @@ -150,11 +150,7 @@ exports.getModule = class FileBaseDownloadQueueManager extends MenuModule { return cb(Errors.DoesNotExist('Queue view does not exist')); } - const queueListFormat = this.menuConfig.config.queueListFormat || '{fileName} {byteSize}'; - const focusQueueListFormat = this.menuConfig.config.focusQueueListFormat || queueListFormat; - - queueView.setItems(this.dlQueue.items.map( queueItem => stringFormat(queueListFormat, queueItem) ) ); - queueView.setFocusItems(this.dlQueue.items.map( queueItem => stringFormat(focusQueueListFormat, queueItem) ) ); + queueView.setItems(this.dlQueue.items); queueView.on('index update', idx => { const fileEntry = this.dlQueue.items[idx]; diff --git a/core/file_base_filter.js b/core/file_base_filter.js index 82a75986..d72b3eea 100644 --- a/core/file_base_filter.js +++ b/core/file_base_filter.js @@ -1,9 +1,11 @@ /* jslint node: true */ 'use strict'; +const UserProps = require('./user_property.js'); + // deps -const _ = require('lodash'); -const uuidV4 = require('uuid/v4'); +const _ = require('lodash'); +const uuidV4 = require('uuid/v4'); module.exports = class FileBaseFilters { constructor(client) { @@ -64,7 +66,7 @@ module.exports = class FileBaseFilters { } load() { - let filtersProperty = this.client.user.properties.file_base_filters; + let filtersProperty = this.client.user.properties[UserProps.FileBaseFilters]; let defaulted; if(!filtersProperty) { filtersProperty = JSON.stringify(FileBaseFilters.getBuiltInSystemFilters()); @@ -90,7 +92,7 @@ module.exports = class FileBaseFilters { } persist(cb) { - return this.client.user.persistProperty('file_base_filters', JSON.stringify(this.filters), cb); + return this.client.user.persistProperty(UserProps.FileBaseFilters, JSON.stringify(this.filters), cb); } cleanTags(tags) { @@ -102,7 +104,7 @@ module.exports = class FileBaseFilters { if(activeFilter) { this.activeFilter = activeFilter; - this.client.user.persistProperty('file_base_filter_active_uuid', filterUuid); + this.client.user.persistProperty(UserProps.FileBaseFilterActiveUuid, filterUuid); return true; } @@ -129,11 +131,11 @@ module.exports = class FileBaseFilters { } static getActiveFilter(client) { - return new FileBaseFilters(client).get(client.user.properties.file_base_filter_active_uuid); + return new FileBaseFilters(client).get(client.user.properties[UserProps.FileBaseFilterActiveUuid]); } static getFileBaseLastViewedFileIdByUser(user) { - return parseInt((user.properties.user_file_base_last_viewed || 0)); + return parseInt((user.properties[UserProps.FileBaseLastViewedId] || 0)); } static setFileBaseLastViewedFileIdForUser(user, fileId, allowOlder, cb) { @@ -150,6 +152,6 @@ module.exports = class FileBaseFilters { return; } - return user.persistProperty('user_file_base_last_viewed', fileId, cb); + return user.persistProperty(UserProps.FileBaseLastViewedId, fileId, cb); } }; diff --git a/core/file_base_web_download_manager.js b/core/file_base_web_download_manager.js index 02bffa3e..62ee02eb 100644 --- a/core/file_base_web_download_manager.js +++ b/core/file_base_web_download_manager.js @@ -121,11 +121,7 @@ exports.getModule = class FileBaseWebDownloadQueueManager extends MenuModule { return cb(Errors.DoesNotExist('Queue view does not exist')); } - const queueListFormat = this.menuConfig.config.queueListFormat || '{webDlLink}'; - const focusQueueListFormat = this.menuConfig.config.focusQueueListFormat || queueListFormat; - - queueView.setItems(this.dlQueue.items.map( queueItem => stringFormat(queueListFormat, queueItem) ) ); - queueView.setFocusItems(this.dlQueue.items.map( queueItem => stringFormat(focusQueueListFormat, queueItem) ) ); + queueView.setItems(this.dlQueue.items); queueView.on('index update', idx => { const fileEntry = this.dlQueue.items[idx]; diff --git a/core/fse.js b/core/fse.js index fcf27ad4..9ecf3bdd 100644 --- a/core/fse.js +++ b/core/fse.js @@ -24,6 +24,7 @@ const { const Config = require('./config.js').get; const { getAddressedToInfo } = require('./mail_util.js'); const Events = require('./events.js'); +const UserProps = require('./user_property.js'); // deps const async = require('async'); @@ -479,7 +480,7 @@ exports.FullScreenEditorModule = exports.getModule = class FullScreenEditorModul } Events.emit(Events.getSystemEvents().UserPostMessage, { user : this.client.user, areaTag : this.message.areaTag }); - return StatLog.incrementUserStat(this.client.user, 'post_count', 1, cb); + return StatLog.incrementUserStat(this.client.user, UserProps.MessagePostCount, 1, cb); } redrawFooter(options, cb) { @@ -542,7 +543,7 @@ exports.FullScreenEditorModule = exports.getModule = class FullScreenEditorModul theme.displayThemedAsset( art[n], self.client, - { font : self.menuConfig.font, acsCondMember : 'art' }, + { font : self.menuConfig.font }, function displayed(err) { next(err); } @@ -622,7 +623,7 @@ exports.FullScreenEditorModule = exports.getModule = class FullScreenEditorModul theme.displayThemedAsset( art[n], self.client, - { font : self.menuConfig.font, acsCondMember : 'art' }, + { font : self.menuConfig.font }, function displayed(err, artData) { if(artData) { mciData[n] = artData; @@ -738,7 +739,7 @@ exports.FullScreenEditorModule = exports.getModule = class FullScreenEditorModul const fromView = self.viewControllers.header.getView(MciViewIds.header.from); const area = getMessageAreaByTag(self.messageAreaTag); if(area && area.realNames) { - fromView.setText(self.client.user.properties.real_name || self.client.user.username); + fromView.setText(self.client.user.properties[UserProps.RealName] || self.client.user.username); } else { fromView.setText(self.client.user.username); } diff --git a/core/last_callers.js b/core/last_callers.js index 08eb7b71..6b25caf6 100644 --- a/core/last_callers.js +++ b/core/last_callers.js @@ -7,6 +7,7 @@ const StatLog = require('./stat_log.js'); const User = require('./user.js'); const sysDb = require('./database.js').dbs.system; const { Errors } = require('./enig_error.js'); +const UserProps = require('./user_property.js'); // deps const moment = require('moment'); @@ -165,7 +166,7 @@ exports.getModule = class LastCallersModule extends MenuModule { loadUserForHistoryItems(loginHistory, cb) { const getPropOpts = { - names : [ 'real_name', 'location', 'affiliation' ] + names : [ UserProps.RealName, UserProps.Location, UserProps.Affiliations ] }; const actionIndicatorNames = _.map(this.actionIndicators, (v, k) => k); @@ -185,9 +186,9 @@ exports.getModule = class LastCallersModule extends MenuModule { item.userName = item.text = userName; User.loadProperties(item.userId, getPropOpts, (err, props) => { - item.location = (props && props.location) || ''; - item.affiliation = item.affils = (props && props.affiliation) || ''; - item.realName = (props && props.real_name) || ''; + item.location = (props && props[UserProps.Location]) || ''; + item.affiliation = item.affils = (props && props[UserProps.Affiliations]) || ''; + item.realName = (props && props[UserProps.RealName]) || ''; if(!indicatorSumsSql) { return next(null, item); diff --git a/core/login_server_module.js b/core/login_server_module.js index d1a3552f..e5fccb39 100644 --- a/core/login_server_module.js +++ b/core/login_server_module.js @@ -6,6 +6,7 @@ const conf = require('./config.js'); const logger = require('./logger.js'); const ServerModule = require('./server_module.js').ServerModule; const clientConns = require('./client_connections.js'); +const UserProps = require('./user_property.js'); // deps const _ = require('lodash'); @@ -25,12 +26,12 @@ module.exports = class LoginServerModule extends ServerModule { // const preLoginTheme = _.get(conf.config, 'theme.preLogin'); if('*' === preLoginTheme) { - client.user.properties.theme_id = theme.getRandomTheme() || ''; + client.user.properties[UserProps.ThemeId] = theme.getRandomTheme() || ''; } else { - client.user.properties.theme_id = preLoginTheme; + client.user.properties[UserProps.ThemeId] = preLoginTheme; } - theme.setClientTheme(client, client.user.properties.theme_id); + theme.setClientTheme(client, client.user.properties[UserProps.ThemeId]); return cb(null); // note: currently useless to use cb here - but this may change...again... } diff --git a/core/menu_module.js b/core/menu_module.js index bd75c94c..2ad30b8a 100644 --- a/core/menu_module.js +++ b/core/menu_module.js @@ -47,6 +47,11 @@ exports.MenuModule = class MenuModule extends PluginModule { const mciData = {}; let pausePosition; + const hasArt = () => { + return _.isString(self.menuConfig.art) || + (Array.isArray(self.menuConfig.art) && _.has(self.menuConfig.art[0], 'acs')); + }; + async.series( [ function beforeArtInterrupt(callback) { @@ -56,7 +61,7 @@ exports.MenuModule = class MenuModule extends PluginModule { return self.beforeArt(callback); }, function displayMenuArt(callback) { - if(!_.isString(self.menuConfig.art)) { + if(!hasArt()) { return callback(null); } diff --git a/core/message_area.js b/core/message_area.js index e5227ef0..a465b235 100644 --- a/core/message_area.js +++ b/core/message_area.js @@ -8,6 +8,7 @@ const Message = require('./message.js'); const Log = require('./logger.js').log; const msgNetRecord = require('./msg_network.js').recordMessage; const sortAreasOrConfs = require('./conf_area_util.js').sortAreasOrConfs; +const UserProps = require('./user_property.js'); // deps const async = require('async'); @@ -222,8 +223,8 @@ function changeMessageConference(client, confTag, cb) { }, function changeConferenceAndArea(conf, areaInfo, callback) { const newProps = { - message_conf_tag : confTag, - message_area_tag : areaInfo.areaTag, + [ UserProps.MessageConfTag ] : confTag, + [ UserProps.MessageAreaTag ] : areaInfo.areaTag, }; client.user.persistProperties(newProps, err => { callback(err, conf, areaInfo); @@ -262,11 +263,11 @@ function changeMessageAreaWithOptions(client, areaTag, options, cb) { }, function changeArea(area, callback) { if(true === options.persist) { - client.user.persistProperty('message_area_tag', areaTag, function persisted(err) { + client.user.persistProperty(UserProps.MessageAreaTag, areaTag, function persisted(err) { return callback(err, area); }); } else { - client.user.properties['message_area_tag'] = areaTag; + client.user.properties[UserProps.MessageAreaTag] = areaTag; return callback(null, area); } } @@ -303,8 +304,8 @@ function tempChangeMessageConfAndArea(client, areaTag) { return false; } - client.user.properties.message_conf_tag = confTag; - client.user.properties.message_area_tag = areaTag; + client.user.properties[UserProps.MessageConfTag] = confTag; + client.user.properties[UserProps.MessageAreaTag] = areaTag; return true; } @@ -353,13 +354,19 @@ function getNewMessagesInAreaForUser(userId, areaTag, cb) { }); } -function getMessageListForArea(client, areaTag, cb) { - const filter = { - areaTag, - resultType : 'messageList', - sort : 'messageId', - order : 'ascending', - }; +function getMessageListForArea(client, areaTag, filter, cb) +{ + if(!cb && _.isFunction(filter)) { + cb = filter; + filter = { + areaTag, + resultType : 'messageList', + sort : 'messageId', + order : 'ascending' + }; + } else { + Object.assign(filter, { areaTag } ); + } if(Message.isPrivateAreaTag(areaTag)) { filter.privateTagUserId = client.user.userId; diff --git a/core/misc_util.js b/core/misc_util.js index 6e477821..62a3967d 100644 --- a/core/misc_util.js +++ b/core/misc_util.js @@ -4,6 +4,8 @@ const paths = require('path'); const os = require('os'); +const moment = require('moment'); + const packageJson = require('../package.json'); exports.isProduction = isProduction; @@ -57,4 +59,4 @@ function valueAsArray(value) { return []; } return Array.isArray(value) ? value : [ value ]; -} \ No newline at end of file +} diff --git a/core/mod_mixins.js b/core/mod_mixins.js index f3d9d5ad..22e49407 100644 --- a/core/mod_mixins.js +++ b/core/mod_mixins.js @@ -2,8 +2,10 @@ 'use strict'; const messageArea = require('../core/message_area.js'); -const { get } = require('lodash'); +const UserProps = require('./user_property.js'); +// deps +const { get } = require('lodash'); exports.MessageAreaConfTempSwitcher = Sup => class extends Sup { @@ -15,8 +17,8 @@ exports.MessageAreaConfTempSwitcher = Sup => class extends Sup { if(recordPrevious) { this.prevMessageConfAndArea = { - confTag : this.client.user.properties.message_conf_tag, - areaTag : this.client.user.properties.message_area_tag, + confTag : this.client.user.properties[UserProps.MessageConfTag], + areaTag : this.client.user.properties[UserProps.MessageAreaTag], }; } @@ -27,8 +29,8 @@ exports.MessageAreaConfTempSwitcher = Sup => class extends Sup { tempMessageConfAndAreaRestore() { if(this.prevMessageConfAndArea) { - this.client.user.properties.message_conf_tag = this.prevMessageConfAndArea.confTag; - this.client.user.properties.message_area_tag = this.prevMessageConfAndArea.areaTag; + this.client.user.properties[UserProps.MessageConfTag] = this.prevMessageConfAndArea.confTag; + this.client.user.properties[UserProps.MessageAreaTag] = this.prevMessageConfAndArea.areaTag; } } }; diff --git a/core/msg_area_list.js b/core/msg_area_list.js index 97a2e16e..1d47f76c 100644 --- a/core/msg_area_list.js +++ b/core/msg_area_list.js @@ -5,6 +5,7 @@ const { MenuModule } = require('./menu_module.js'); const messageArea = require('./message_area.js'); const { Errors } = require('./enig_error.js'); +const UserProps = require('./user_property.js'); // deps const async = require('async'); @@ -110,7 +111,7 @@ exports.getModule = class MessageAreaListModule extends MenuModule { initList() { let index = 1; this.messageAreas = messageArea.getSortedAvailMessageAreasByConfTag( - this.client.user.properties.message_conf_tag, + this.client.user.properties[UserProps.MessageConfTag], { client : this.client } ).map(area => { return { diff --git a/core/msg_area_post_fse.js b/core/msg_area_post_fse.js index 8e4d9f3f..123ce13c 100644 --- a/core/msg_area_post_fse.js +++ b/core/msg_area_post_fse.js @@ -3,6 +3,7 @@ const FullScreenEditorModule = require('./fse.js').FullScreenEditorModule; const persistMessage = require('./message_area.js').persistMessage; +const UserProps = require('./user_property.js'); const _ = require('lodash'); const async = require('async'); @@ -58,8 +59,10 @@ exports.getModule = class AreaPostFSEModule extends FullScreenEditorModule { } enter() { - if(_.isString(this.client.user.properties.message_area_tag) && !_.isString(this.messageAreaTag)) { - this.messageAreaTag = this.client.user.properties.message_area_tag; + if(_.isString(this.client.user.properties[UserProps.MessageAreaTag]) && + !_.isString(this.messageAreaTag)) + { + this.messageAreaTag = this.client.user.properties[UserProps.MessageAreaTag]; } super.enter(); diff --git a/core/msg_list.js b/core/msg_list.js index 2796db88..f73dae8a 100644 --- a/core/msg_list.js +++ b/core/msg_list.js @@ -8,6 +8,7 @@ const messageArea = require('./message_area.js'); const MessageAreaConfTempSwitcher = require('./mod_mixins.js').MessageAreaConfTempSwitcher; const Errors = require('./enig_error.js').Errors; const Message = require('./message.js'); +const UserProps = require('./user_property.js'); // deps const async = require('async'); @@ -167,7 +168,7 @@ exports.getModule = class MessageListModule extends MessageAreaConfTempSwitcher( if(this.config.messageAreaTag) { this.tempMessageConfAndAreaSwitch(this.config.messageAreaTag); } else { - this.config.messageAreaTag = this.client.user.properties.message_area_tag; + this.config.messageAreaTag = this.client.user.properties[UserProps.MessageAreaTag]; } } } diff --git a/core/nua.js b/core/nua.js index 2d838fcb..42c63895 100644 --- a/core/nua.js +++ b/core/nua.js @@ -8,9 +8,14 @@ const theme = require('./theme.js'); const login = require('./system_menu_method.js').login; const Config = require('./config.js').get; const messageArea = require('./message_area.js'); +const { + getISOTimestampString +} = require('./database.js'); +const UserProps = require('./user_property.js'); // deps const _ = require('lodash'); +const moment = require('moment'); exports.moduleInfo = { name : 'NUA', @@ -80,20 +85,20 @@ exports.getModule = class NewUserAppModule extends MenuModule { areaTag = areaTag || ''; newUser.properties = { - real_name : formData.value.realName, - birthdate : new Date(Date.parse(formData.value.birthdate)).toISOString(), // :TODO: Use moment & explicit ISO string format - sex : formData.value.sex, - location : formData.value.location, - affiliation : formData.value.affils, - email_address : formData.value.email, - web_address : formData.value.web, - account_created : new Date().toISOString(), // :TODO: Use moment & explicit ISO string format + [ UserProps.RealName ] : formData.value.realName, + [ UserProps.Birthdate ] : getISOTimestampString(formData.value.birthdate), + [ UserProps.Sex ] : formData.value.sex, + [ UserProps.Location ] : formData.value.location, + [ UserProps.Affiliations ] : formData.value.affils, + [ UserProps.EmailAddress ] : formData.value.email, + [ UserProps.WebAddress ] : formData.value.web, + [ UserProps.AccountCreated ] : getISOTimestampString(), - message_conf_tag : confTag, - message_area_tag : areaTag, + [ UserProps.MessageConfTag ] : confTag, + [ UserProps.MessageAreaTag ] : areaTag, - term_height : self.client.term.termHeight, - term_width : self.client.term.termWidth, + [ UserProps.TermHeight ] : self.client.term.termHeight, + [ UserProps.TermWidth ] : self.client.term.termWidth, // :TODO: Other defaults // :TODO: should probably have a place to create defaults/etc. @@ -101,9 +106,9 @@ exports.getModule = class NewUserAppModule extends MenuModule { const defaultTheme = _.get(config, 'theme.default'); if('*' === defaultTheme) { - newUser.properties.theme_id = theme.getRandomTheme(); + newUser.properties[UserProps.ThemeId] = theme.getRandomTheme(); } else { - newUser.properties.theme_id = defaultTheme; + newUser.properties[UserProps.ThemeId] = defaultTheme; } // :TODO: User.create() should validate email uniqueness! @@ -133,7 +138,7 @@ exports.getModule = class NewUserAppModule extends MenuModule { }; } - if(User.AccountStatus.inactive === self.client.user.properties.account_status) { + if(User.AccountStatus.inactive === self.client.user.properties[UserProps.AccountStatus]) { return self.gotoMenu(extraArgs.inactive, cb); } else { // diff --git a/core/oputil/oputil_config.js b/core/oputil/oputil_config.js index 83ac5232..52c388f7 100644 --- a/core/oputil/oputil_config.js +++ b/core/oputil/oputil_config.js @@ -38,7 +38,7 @@ function getAnswers(questions, cb) { const ConfigIncludeKeys = [ 'theme', 'users.preAuthIdleLogoutSeconds', 'users.idleLogoutSeconds', - 'users.newUserNames', + 'users.newUserNames', 'users.failedLogin', 'users.unlockAtEmailPwReset', 'paths.logs', 'loginServers', 'contentServers', diff --git a/core/oputil/oputil_help.js b/core/oputil/oputil_help.js index 7c96d171..4d7931e0 100644 --- a/core/oputil/oputil_help.js +++ b/core/oputil/oputil_help.js @@ -26,10 +26,11 @@ commands: actions: pw USERNAME PASSWORD set password to PASSWORD for USERNAME - rm USERNAME permanantely removes USERNAME user from system + rm USERNAME permanently removes USERNAME user from system activate USERNAME sets USERNAME's status to active - deactivate USERNAME sets USERNAME's status to deactive + deactivate USERNAME sets USERNAME's status to inactive disable USERNAME sets USERNAME's status to disabled + lock USERNAME sets USERNAME's status to locked group USERNAME [+|-]GROUP adds (+) or removes (-) user from GROUP `, @@ -57,7 +58,7 @@ cat args: actions: scan AREA_TAG[@STORAGE_TAG] scan specified area may also contain optional GLOB as last parameter, - for examle: scan some_area *.zip + for example: scan some_area *.zip info CRITERIA display information about areas and/or files where CRITERIA is one of the following: diff --git a/core/oputil/oputil_main.js b/core/oputil/oputil_main.js index d9492c0d..aafc8ef1 100644 --- a/core/oputil/oputil_main.js +++ b/core/oputil/oputil_main.js @@ -17,7 +17,7 @@ module.exports = function() { process.exitCode = ExitCodes.SUCCESS; if(true === argv.version) { - return console.info(require('../package.json').version); + return console.info(require('../../package.json').version); } if(0 === argv._.length || diff --git a/core/oputil/oputil_user.js b/core/oputil/oputil_user.js index 60d3888d..18519b06 100644 --- a/core/oputil/oputil_user.js +++ b/core/oputil/oputil_user.js @@ -8,25 +8,13 @@ const argv = require('./oputil_common.js').argv; const initConfigAndDatabases = require('./oputil_common.js').initConfigAndDatabases; const getHelpFor = require('./oputil_help.js').getHelpFor; const Errors = require('../enig_error.js').Errors; +const UserProps = require('../user_property.js'); const async = require('async'); const _ = require('lodash'); exports.handleUserCommand = handleUserCommand; -function getUser(userName, cb) { - const User = require('../../core/user.js'); - User.getUserIdAndName(userName, (err, userId) => { - if(err) { - process.exitCode = ExitCodes.BAD_ARGS; - return cb(err); - } - const u = new User(); - u.userId = userId; - return cb(null, u); - }); -} - function initAndGetUser(userName, cb) { async.waterfall( [ @@ -34,12 +22,12 @@ function initAndGetUser(userName, cb) { initConfigAndDatabases(callback); }, function getUserObject(callback) { - getUser(userName, (err, user) => { + const User = require('../../core/user.js'); + User.getUserIdAndName(userName, (err, userId) => { if(err) { - process.exitCode = ExitCodes.BAD_ARGS; return callback(err); } - return callback(null, user); + return User.getUser(userId, callback); }); } ], @@ -55,15 +43,38 @@ function setAccountStatus(user, status) { } const AccountStatus = require('../../core/user.js').AccountStatus; + + status = { + activate : AccountStatus.active, + deactivate : AccountStatus.inactive, + disable : AccountStatus.disabled, + lock : AccountStatus.locked, + }[status]; + const statusDesc = _.invert(AccountStatus)[status]; - user.persistProperty('account_status', status, err => { - if(err) { - process.exitCode = ExitCodes.ERROR; - console.error(err.message); - } else { - console.info(`User status set to ${statusDesc}`); + + async.series( + [ + (callback) => { + return user.persistProperty(UserProps.AccountStatus, status, callback); + }, + (callback) => { + if(AccountStatus.active !== status) { + return callback(null); + } + + return user.unlockAccount(callback); + } + ], + err => { + if(err) { + process.exitCode = ExitCodes.ERROR; + console.error(err.message); + } else { + console.info(`User status set to ${statusDesc}`); + } } - }); + ); } function setUserPassword(user) { @@ -147,21 +158,6 @@ function modUserGroups(user) { } } -function activateUser(user) { - const AccountStatus = require('../../core/user.js').AccountStatus; - return setAccountStatus(user, AccountStatus.active); -} - -function deactivateUser(user) { - const AccountStatus = require('../../core/user.js').AccountStatus; - return setAccountStatus(user, AccountStatus.inactive); -} - -function disableUser(user) { - const AccountStatus = require('../../core/user.js').AccountStatus; - return setAccountStatus(user, AccountStatus.disabled); -} - function handleUserCommand() { function errUsage() { return printUsageAndSetExitCode(getHelpFor('User'), ExitCodes.ERROR); @@ -195,11 +191,12 @@ function handleUserCommand() { del : removeUser, delete : removeUser, - activate : activateUser, - deactivate : deactivateUser, - disable : disableUser, + activate : setAccountStatus, + deactivate : setAccountStatus, + disable : setAccountStatus, + lock : setAccountStatus, group : modUserGroups, - }[action] || errUsage)(user); + }[action] || errUsage)(user, action); }); } \ No newline at end of file diff --git a/core/predefined_mci.js b/core/predefined_mci.js index 9888ba5e..c83f8353 100644 --- a/core/predefined_mci.js +++ b/core/predefined_mci.js @@ -2,17 +2,18 @@ 'use strict'; // ENiGMA½ -const Config = require('./config.js').get; -const Log = require('./logger.js').log; +const Config = require('./config.js').get; +const Log = require('./logger.js').log; const { getMessageAreaByTag, getMessageConferenceByTag -} = require('./message_area.js'); -const clientConnections = require('./client_connections.js'); -const StatLog = require('./stat_log.js'); -const FileBaseFilters = require('./file_base_filter.js'); -const { formatByteSize } = require('./string_util.js'); -const ANSI = require('./ansi_term.js'); +} = require('./message_area.js'); +const clientConnections = require('./client_connections.js'); +const StatLog = require('./stat_log.js'); +const FileBaseFilters = require('./file_base_filter.js'); +const { formatByteSize } = require('./string_util.js'); +const ANSI = require('./ansi_term.js'); +const UserProps = require('./user_property.js'); // deps const packageJson = require('../package.json'); @@ -80,62 +81,66 @@ const PREDEFINED_MCI_GENERATORS = { UN : function userName(client) { return client.user.username; }, UI : function userId(client) { return client.user.userId.toString(); }, UG : function groups(client) { return _.values(client.user.groups).join(', '); }, - UR : function realName(client) { return userStatAsString(client, 'real_name', ''); }, - LO : function location(client) { return userStatAsString(client, 'location', ''); }, + UR : function realName(client) { return userStatAsString(client, UserProps.RealName, ''); }, + LO : function location(client) { return userStatAsString(client, UserProps.Location, ''); }, UA : function age(client) { return client.user.getAge().toString(); }, - BD : function birthdate(client) { return moment(client.user.properties.birthdate).format(client.currentTheme.helpers.getDateFormat()); }, // iNiQUiTY - US : function sex(client) { return userStatAsString(client, 'sex', ''); }, - UE : function emailAddres(client) { return userStatAsString(client, 'email_address', ''); }, - UW : function webAddress(client) { return userStatAsString(client, 'web_address', ''); }, - UF : function affils(client) { return userStatAsString(client, 'affiliation', ''); }, - UT : function themeId(client) { return userStatAsString(client, 'theme_id', ''); }, - UC : function loginCount(client) { return userStatAsString(client, 'login_count', 0); }, + BD : function birthdate(client) { // iNiQUiTY + return moment(client.user.properties[UserProps.Birthdate]).format(client.currentTheme.helpers.getDateFormat()); + }, + US : function sex(client) { return userStatAsString(client, UserProps.Sex, ''); }, + UE : function emailAddres(client) { return userStatAsString(client, UserProps.EmailAddress, ''); }, + UW : function webAddress(client) { return userStatAsString(client, UserProps.WebAddress, ''); }, + UF : function affils(client) { return userStatAsString(client, UserProps.Affiliations, ''); }, + UT : function themeId(client) { return userStatAsString(client, UserProps.ThemeId, ''); }, + UC : function loginCount(client) { return userStatAsString(client, UserProps.LoginCount, 0); }, ND : function connectedNode(client) { return client.node.toString(); }, IP : function clientIpAddress(client) { return client.remoteAddress.replace(/^::ffff:/, ''); }, // convert any :ffff: IPv4's to 32bit version ST : function serverName(client) { return client.session.serverName; }, FN : function activeFileBaseFilterName(client) { const activeFilter = FileBaseFilters.getActiveFilter(client); - return activeFilter ? activeFilter.name : ''; + return activeFilter ? activeFilter.name : '(Unknown)'; }, - DN : function userNumDownloads(client) { return userStatAsString(client, 'dl_total_count', 0); }, // Obv/2 + DN : function userNumDownloads(client) { return userStatAsString(client, UserProps.FileDlTotalCount, 0); }, // Obv/2 DK : function userByteDownload(client) { // Obv/2 uses DK=downloaded Kbytes - const byteSize = StatLog.getUserStatNum(client.user, 'dl_total_bytes'); + const byteSize = StatLog.getUserStatNum(client.user, UserProps.FileDlTotalBytes); return formatByteSize(byteSize, true); // true=withAbbr }, - UP : function userNumUploads(client) { return userStatAsString(client, 'ul_total_count', 0); }, // Obv/2 + UP : function userNumUploads(client) { return userStatAsString(client, UserProps.FileUlTotalCount, 0); }, // Obv/2 UK : function userByteUpload(client) { // Obv/2 uses UK=uploaded Kbytes - const byteSize = StatLog.getUserStatNum(client.user, 'ul_total_bytes'); + const byteSize = StatLog.getUserStatNum(client.user, UserProps.FileUlTotalBytes); return formatByteSize(byteSize, true); // true=withAbbr }, NR : function userUpDownRatio(client) { // Obv/2 - return getUserRatio(client, 'ul_total_count', 'dl_total_count'); + return getUserRatio(client, UserProps.FileUlTotalCount, UserProps.FileDlTotalCount); }, KR : function userUpDownByteRatio(client) { // Obv/2 uses KR=upload/download Kbyte ratio - return getUserRatio(client, 'ul_total_bytes', 'dl_total_bytes'); + return getUserRatio(client, UserProps.FileUlTotalBytes, UserProps.FileDlTotalBytes); }, - MS : function accountCreatedclient(client) { return moment(client.user.properties.account_created).format(client.currentTheme.helpers.getDateFormat()); }, - PS : function userPostCount(client) { return userStatAsString(client, 'post_count', 0); }, - PC : function userPostCallRatio(client) { return getUserRatio(client, 'post_count', 'login_count'); }, + MS : function accountCreatedclient(client) { + return moment(client.user.properties[UserProps.AccountCreated]).format(client.currentTheme.helpers.getDateFormat()); + }, + PS : function userPostCount(client) { return userStatAsString(client, UserProps.MessagePostCount, 0); }, + PC : function userPostCallRatio(client) { return getUserRatio(client, UserProps.MessagePostCount, UserProps.LoginCount); }, MD : function currentMenuDescription(client) { return _.has(client, 'currentMenuModule.menuConfig.desc') ? client.currentMenuModule.menuConfig.desc : ''; }, MA : function messageAreaName(client) { - const area = getMessageAreaByTag(client.user.properties.message_area_tag); + const area = getMessageAreaByTag(client.user.properties[UserProps.MessageAreaTag]); return area ? area.name : ''; }, MC : function messageConfName(client) { - const conf = getMessageConferenceByTag(client.user.properties.message_conf_tag); + const conf = getMessageConferenceByTag(client.user.properties[UserProps.MessageConfTag]); return conf ? conf.name : ''; }, ML : function messageAreaDescription(client) { - const area = getMessageAreaByTag(client.user.properties.message_area_tag); + const area = getMessageAreaByTag(client.user.properties[UserProps.MessageAreaTag]); return area ? area.desc : ''; }, CM : function messageConfDescription(client) { - const conf = getMessageConferenceByTag(client.user.properties.message_conf_tag); + const conf = getMessageConferenceByTag(client.user.properties[UserProps.MessageConfTag]); return conf ? conf.desc : ''; }, @@ -169,8 +174,9 @@ const PREDEFINED_MCI_GENERATORS = { // Clean up CPU strings a bit for better display // return os.cpus()[0].model - .replace(/\(R\)|\(TM\)|processor|CPU/g, '') - .replace(/\s+(?= )/g, ''); + .replace(/\(R\)|\(TM\)|processor|CPU/ig, '') + .replace(/\s+(?= )/g, '') + .trim(); }, // :TODO: MCI for core count, e.g. os.cpus().length diff --git a/core/scanner_tossers/ftn_bso.js b/core/scanner_tossers/ftn_bso.js index e2260d93..3292588d 100644 --- a/core/scanner_tossers/ftn_bso.js +++ b/core/scanner_tossers/ftn_bso.js @@ -1746,7 +1746,7 @@ function FTNMessageScanTossModule() { } return callback(null, localInfo); // continue even if we couldn't find an old match }); - } else if(fileIds.legnth > 1) { + } else if(fileIds.length > 1) { return callback(Errors.General(`More than one existing entry for TIC in ${localInfo.areaTag} ([${fileIds.join(', ')}])`)); } else { return callback(null, localInfo); diff --git a/core/servers/content/gopher.js b/core/servers/content/gopher.js index 240236d1..da09acd9 100644 --- a/core/servers/content/gopher.js +++ b/core/servers/content/gopher.js @@ -17,6 +17,7 @@ const { } = require('../../message_area.js'); const { sortAreasOrConfs } = require('../../conf_area_util.js'); const AnsiPrep = require('../../ansi_prep.js'); +const { wordWrapText } = require('../../word_wrap.js'); // deps const net = require('net'); @@ -27,9 +28,10 @@ const moment = require('moment'); const ModuleInfo = exports.moduleInfo = { name : 'Gopher', - desc : 'Gopher Server', + desc : 'A RFC-1436-ish Gopher Server', author : 'NuSkooler', packageName : 'codes.l33t.enigma.gopher.server', + notes : 'https://tools.ietf.org/html/rfc1436', }; const Message = require('../../message.js'); @@ -158,7 +160,7 @@ exports.getModule = class GopherModule extends ServerModule { defaultGenerator(selectorMatch, cb) { this.log.debug( { selector : selectorMatch[0] }, 'Serving default content'); - let bannerFile = _.get(Config(), 'contentServers.gopher.bannerFile', 'startup_banner.asc'); + let bannerFile = _.get(Config(), 'contentServers.gopher.bannerFile', 'gopher_banner.asc'); bannerFile = paths.isAbsolute(bannerFile) ? bannerFile : paths.join(__dirname, '../../../misc', bannerFile); fs.readFile(bannerFile, 'utf8', (err, banner) => { if(err) { @@ -182,21 +184,43 @@ exports.getModule = class GopherModule extends ServerModule { } prepareMessageBody(body, cb) { + // + // From RFC-1436: + // "User display strings are intended to be displayed on a line on a + // typical screen for a user's viewing pleasure. While many screens can + // accommodate 80 character lines, some space is needed to display a tag + // of some sort to tell the user what sort of item this is. Because of + // this, the user display string should be kept under 70 characters in + // length. Clients may truncate to a length convenient to them." + // + // Messages on BBSes however, have generally been <= 79 characters. If we + // start wrapping earlier, things will generally be OK except: + // * When we're doing with FTN-style quoted lines + // * When dealing with ANSI/ASCII art + // + // Anyway, the spec says "should" and not MUST or even SHOULD! ...so, to + // to follow the KISS principle: Wrap at 79. + // + const WordWrapColumn = 79; if(isAnsi(body)) { AnsiPrep( body, { - cols : 79, // Gopher std. wants 70, but we'll have to deal with it. - forceLineTerm : true, // ensure each line is term'd - asciiMode : true, // export to ASCII - fillLines : false, // don't fill up to |cols| + cols : WordWrapColumn, // See notes above + forceLineTerm : true, // Ensure each line is term'd + asciiMode : true, // Export to ASCII + fillLines : false, // Don't fill up to |cols| }, (err, prepped) => { return cb(prepped || body); } ); } else { - return cb(cleanControlCodes(body, { all : true } )); + const prepped = splitTextAtTerms(cleanControlCodes(body, { all : true } ) ) + .map(l => (wordWrapText(l, { width : WordWrapColumn } ).wrapped || []).join('\n')) + .join('\n'); + + return cb(prepped); } } @@ -225,7 +249,7 @@ exports.getModule = class GopherModule extends ServerModule { return message.load( { uuid : msgUuid }, err => { if(err) { - this.log.debug( { uuid : msgUuid }, 'Attempted access to non-existant message UUID!'); + this.log.debug( { uuid : msgUuid }, 'Attempted access to non-existent message UUID!'); return this.notFoundGenerator(selectorMatch, cb); } @@ -268,10 +292,17 @@ ${msgBody} return this.notFoundGenerator(selectorMatch, cb); } - return getMessageListForArea(null, areaTag, (err, msgList) => { + const filter = { + resultType : 'messageList', + sort : 'messageId', + order : 'descending', // we want newest messages first for Gopher + }; + + return getMessageListForArea(null, areaTag, filter, (err, msgList) => { const response = [ this.makeItem(ItemTypes.InfoMessage, '-'.repeat(70)), this.makeItem(ItemTypes.InfoMessage, `Messages in ${area.name}`), + this.makeItem(ItemTypes.InfoMessage, '(newest first)'), this.makeItem(ItemTypes.InfoMessage, '-'.repeat(70)), ...msgList.map(msg => this.makeItem( ItemTypes.TextFile, diff --git a/core/servers/login/ssh.js b/core/servers/login/ssh.js index 72f91a2a..f626cb79 100644 --- a/core/servers/login/ssh.js +++ b/core/servers/login/ssh.js @@ -10,6 +10,10 @@ const userLogin = require('../../user_login.js').userLogin; const enigVersion = require('../../../package.json').version; const theme = require('../../theme.js'); const stringFormat = require('../../string_format.js'); +const { + Errors, + ErrorReasons +} = require('../../enig_error.js'); // deps const ssh2 = require('ssh2'); @@ -36,8 +40,6 @@ function SSHClient(clientConn) { const self = this; - let loginAttempts = 0; - clientConn.on('authentication', function authAttempt(ctx) { const username = ctx.username || ''; const password = ctx.password || ''; @@ -52,26 +54,56 @@ function SSHClient(clientConn) { return clientConn.end(); } - function alreadyLoggedIn(username) { - ctx.prompt(`${username} is already connected to the system. Terminating connection.\n(Press any key to continue)`); + function promptAndTerm(msg) { + if('keyboard-interactive' === ctx.method) { + ctx.prompt(msg); + } return terminateConnection(); } + function accountAlreadyLoggedIn(username) { + return promptAndTerm(`${username} is already connected to the system. Terminating connection.\n(Press any key to continue)`); + } + + function accountDisabled(username) { + return promptAndTerm(`${username} is disabled.\n(Press any key to continue)`); + } + + function accountInactive(username) { + return promptAndTerm(`${username} is waiting for +op activation.\n(Press any key to continue)`); + } + + function accountLocked(username) { + return promptAndTerm(`${username} is locked.\n(Press any key to continue)`); + } + + function isSpecialHandleError(err) { + return [ ErrorReasons.AlreadyLoggedIn, ErrorReasons.Disabled, ErrorReasons.Inactive, ErrorReasons.Locked ].includes(err.reasonCode); + } + + function handleSpecialError(err, username) { + switch(err.reasonCode) { + case ErrorReasons.AlreadyLoggedIn : return accountAlreadyLoggedIn(username); + case ErrorReasons.Inactive : return accountInactive(username); + case ErrorReasons.Disabled : return accountDisabled(username); + case ErrorReasons.Locked : return accountLocked(username); + default : return terminateConnection(); + } + } + // // If the system is open and |isNewUser| is true, the login - // sequence is hijacked in order to start the applicaiton process. + // sequence is hijacked in order to start the application process. // if(false === config.general.closedSystem && self.isNewUser) { return ctx.accept(); } if(username.length > 0 && password.length > 0) { - loginAttempts += 1; - userLogin(self, ctx.username, ctx.password, function authResult(err) { if(err) { - if(err.existingConn) { - return alreadyLoggedIn(username); + if(isSpecialHandleError(err)) { + return handleSpecialError(err, username); } return ctx.reject(SSHClient.ValidAuthMethods); @@ -92,15 +124,13 @@ function SSHClient(clientConn) { const interactivePrompt = { prompt : `${ctx.username}'s password: `, echo : false }; ctx.prompt(interactivePrompt, function retryPrompt(answers) { - loginAttempts += 1; - userLogin(self, username, (answers[0] || ''), err => { if(err) { - if(err.existingConn) { - return alreadyLoggedIn(username); + if(isSpecialHandleError(err)) { + return handleSpecialError(err, username); } - if(loginAttempts >= config.general.loginAttempts) { + if(Errors.BadLogin().code === err.code) { return terminateConnection(); } diff --git a/core/set_newscan_date.js b/core/set_newscan_date.js index 27a27c21..7713f647 100644 --- a/core/set_newscan_date.js +++ b/core/set_newscan_date.js @@ -14,7 +14,7 @@ const { updateMessageAreaLastReadId, getMessageIdNewerThanTimestampByArea } = require('./message_area.js'); -const stringFormat = require('./string_format.js'); +const UserProps = require('./user_property.js'); // deps const async = require('async'); @@ -153,11 +153,13 @@ exports.getModule = class SetNewScanDate extends MenuModule { selections.push({ conf : { confTag : conf.confTag, + text : conf.conf.name, // standard name : conf.conf.name, desc : conf.conf.desc, }, area : { areaTag : area.areaTag, + text : area.area.name, // standard name : area.area.name, desc : area.area.desc, } @@ -168,19 +170,21 @@ exports.getModule = class SetNewScanDate extends MenuModule { selections.unshift({ conf : { confTag : '', + text : 'All conferences', name : 'All conferences', desc : 'All conferences', }, area : { areaTag : '', + text : 'All areas', name : 'All areas', desc : 'All areas', } }); // Find current conf/area & move it directly under "All" - const currConfTag = this.client.user.properties.message_conf_tag; - const currAreaTag = this.client.user.properties.message_area_tag; + const currConfTag = this.client.user.properties[UserProps.MessageConfTag]; + const currAreaTag = this.client.user.properties[UserProps.MessageAreaTag]; if(currConfTag && currAreaTag) { const confAreaIndex = selections.findIndex( confArea => { return confArea.conf.confTag === currConfTag && confArea.area.areaTag === currAreaTag; @@ -236,14 +240,9 @@ exports.getModule = class SetNewScanDate extends MenuModule { scanDateView.setText(today.format(scanDateFormat)); if('message' === self.target) { - const messageSelectionsFormat = self.menuConfig.config.messageSelectionsFormat || '{conf.name} - {area.name}'; - const messageSelectionFocusFormat = self.menuConfig.config.messageSelectionFocusFormat || messageSelectionsFormat; - const targetSelectionView = vc.getView(MciViewIds.main.targetSelection); - targetSelectionView.setItems(self.targetSelections.map(targetSelection => stringFormat(messageSelectionFocusFormat, targetSelection))); - targetSelectionView.setFocusItems(self.targetSelections.map(targetSelection => stringFormat(messageSelectionFocusFormat, targetSelection))); - + targetSelectionView.setItems(self.targetSelections); targetSelectionView.setFocusItemIndex(0); } diff --git a/core/show_art.js b/core/show_art.js index 30b6e56e..a480fa05 100644 --- a/core/show_art.js +++ b/core/show_art.js @@ -68,7 +68,7 @@ exports.getModule = class ShowArtModule extends MenuModule { } showByExtraArgs(cb) { - this.getArtKeyValue( (err, artSpec) => { + this.getArtKeyValue(this.config.key, (err, artSpec) => { if(err) { return cb(err); } @@ -89,7 +89,7 @@ exports.getModule = class ShowArtModule extends MenuModule { } showByFileBaseArea(cb) { - this.getArtKeyValue( (err, key) => { + this.getArtKeyValue('areaTag', (err, key) => { if(err) { return cb(err); } @@ -98,7 +98,7 @@ exports.getModule = class ShowArtModule extends MenuModule { } showByMessageConf(cb) { - this.getArtKeyValue( (err, key) => { + this.getArtKeyValue('confTag', (err, key) => { if(err) { return cb(err); } @@ -107,7 +107,7 @@ exports.getModule = class ShowArtModule extends MenuModule { } showByMessageArea(cb) { - this.getArtKeyValue( (err, key) => { + this.getArtKeyValue('areaTag', (err, key) => { if(err) { return cb(err); } @@ -133,8 +133,8 @@ exports.getModule = class ShowArtModule extends MenuModule { return this.displaySingleArtWithOptions(artSpec, options, cb); } - getArtKeyValue(cb) { - const key = this.config.key; + getArtKeyValue(defaultKey, cb) { + const key = this.config.key || defaultKey; if(!_.isString(key)) { return cb(Errors.MissingConfig('Config option "key" is required for method "extraArgs"')); } diff --git a/core/stat_log.js b/core/stat_log.js index 9e655999..173222b9 100644 --- a/core/stat_log.js +++ b/core/stat_log.js @@ -2,10 +2,12 @@ 'use strict'; const sysDb = require('./database.js').dbs.system; +const { + getISOTimestampString +} = require('./database.js'); // deps const _ = require('lodash'); -const moment = require('moment'); /* System Event Log & Stats @@ -68,6 +70,7 @@ class StatLog { }; } + // :TODO: fix spelling :) setNonPeristentSystemStat(statName, statValue) { this.systemStats[statName] = statValue; } @@ -148,7 +151,9 @@ class StatLog { } // the time "now" in the ISO format we use and love :) - get now() { return moment().format('YYYY-MM-DDTHH:mm:ss.SSSZ'); } + get now() { + return getISOTimestampString(); + } appendSystemLogEntry(logName, logValue, keep, keepType, cb) { sysDb.run( diff --git a/core/system_menu_method.js b/core/system_menu_method.js index 9218e34a..5e7b651b 100644 --- a/core/system_menu_method.js +++ b/core/system_menu_method.js @@ -2,10 +2,12 @@ 'use strict'; // ENiGMA½ -const removeClient = require('./client_connections.js').removeClient; +const { removeClient } = require('./client_connections.js'); const ansiNormal = require('./ansi_term.js').normal; -const userLogin = require('./user_login.js').userLogin; +const { userLogin } = require('./user_login.js'); const messageArea = require('./message_area.js'); +const { ErrorReasons } = require('./enig_error.js'); +const UserProps = require('./user_property.js'); // deps const _ = require('lodash'); @@ -25,13 +27,23 @@ function login(callingMenu, formData, extraArgs, cb) { userLogin(callingMenu.client, formData.value.username, formData.value.password, err => { if(err) { - // login failure - if(err.existingConn && _.has(callingMenu, 'menuConfig.config.tooNodeMenu')) { + // already logged in with this user? + if(ErrorReasons.AlreadyLoggedIn === err.reasonCode && + _.has(callingMenu, 'menuConfig.config.tooNodeMenu')) + { return callingMenu.gotoMenu(callingMenu.menuConfig.config.tooNodeMenu, cb); - } else { - // Other error - return callingMenu.prevMenu(cb); } + + const ReasonsMenus = [ + ErrorReasons.TooMany, ErrorReasons.Disabled, ErrorReasons.Inactive, ErrorReasons.Locked + ]; + if(ReasonsMenus.includes(err.reasonCode)) { + const menu = _.get(callingMenu, [ 'menuConfig', 'config', err.reasonCode.toLowerCase() ]); + return menu ? callingMenu.gotoMenu(menu, cb) : logoff(callingMenu, {}, {}, cb); + } + + // Other error + return callingMenu.prevMenu(cb); } // success! @@ -94,7 +106,7 @@ function reloadMenu(menu, cb) { function prevConf(callingMenu, formData, extraArgs, cb) { const confs = messageArea.getSortedAvailMessageConferences(callingMenu.client); - const currIndex = confs.findIndex( e => e.confTag === callingMenu.client.user.properties.message_conf_tag) || confs.length; + const currIndex = confs.findIndex( e => e.confTag === callingMenu.client.user.properties[UserProps.MessageConfTag]) || confs.length; messageArea.changeMessageConference(callingMenu.client, confs[currIndex - 1].confTag, err => { if(err) { @@ -107,7 +119,7 @@ function prevConf(callingMenu, formData, extraArgs, cb) { function nextConf(callingMenu, formData, extraArgs, cb) { const confs = messageArea.getSortedAvailMessageConferences(callingMenu.client); - let currIndex = confs.findIndex( e => e.confTag === callingMenu.client.user.properties.message_conf_tag); + let currIndex = confs.findIndex( e => e.confTag === callingMenu.client.user.properties[UserProps.MessageConfTag]); if(currIndex === confs.length - 1) { currIndex = -1; @@ -123,8 +135,8 @@ function nextConf(callingMenu, formData, extraArgs, cb) { } function prevArea(callingMenu, formData, extraArgs, cb) { - const areas = messageArea.getSortedAvailMessageAreasByConfTag(callingMenu.client.user.properties.message_conf_tag); - const currIndex = areas.findIndex( e => e.areaTag === callingMenu.client.user.properties.message_area_tag) || areas.length; + const areas = messageArea.getSortedAvailMessageAreasByConfTag(callingMenu.client.user.properties[UserProps.MessageConfTag]); + const currIndex = areas.findIndex( e => e.areaTag === callingMenu.client.user.properties[UserProps.MessageAreaTag]) || areas.length; messageArea.changeMessageArea(callingMenu.client, areas[currIndex - 1].areaTag, err => { if(err) { @@ -136,8 +148,8 @@ function prevArea(callingMenu, formData, extraArgs, cb) { } function nextArea(callingMenu, formData, extraArgs, cb) { - const areas = messageArea.getSortedAvailMessageAreasByConfTag(callingMenu.client.user.properties.message_conf_tag); - let currIndex = areas.findIndex( e => e.areaTag === callingMenu.client.user.properties.message_area_tag); + const areas = messageArea.getSortedAvailMessageAreasByConfTag(callingMenu.client.user.properties[UserProps.MessageConfTag]); + let currIndex = areas.findIndex( e => e.areaTag === callingMenu.client.user.properties[UserProps.MessageAreaTag]); if(currIndex === areas.length - 1) { currIndex = -1; diff --git a/core/theme.js b/core/theme.js index 2f88027e..6dfee685 100644 --- a/core/theme.js +++ b/core/theme.js @@ -13,7 +13,9 @@ const Errors = require('./enig_error.js').Errors; const ErrorReasons = require('./enig_error.js').ErrorReasons; const Events = require('./events.js'); const AnsiPrep = require('./ansi_prep.js'); +const UserProps = require('./user_property.js'); +// deps const fs = require('graceful-fs'); const paths = require('path'); const async = require('async'); @@ -38,7 +40,7 @@ function refreshThemeHelpers(theme) { getPasswordChar : function() { let pwChar = _.get( theme, - 'customization.defaults.general.passwordChar', + 'customization.defaults.passwordChar', Config().theme.passwordChar ); @@ -427,8 +429,8 @@ function getThemeArt(options, cb) { // random // const config = Config(); - if(!options.themeId && _.has(options, 'client.user.properties.theme_id')) { - options.themeId = options.client.user.properties.theme_id; + if(!options.themeId && _.has(options, [ 'client', 'user', 'properties', UserProps.ThemeId ])) { + options.themeId = options.client.user.properties[UserProps.ThemeId]; } else { options.themeId = config.theme.default; } @@ -682,8 +684,9 @@ function displayThemedAsset(assetSpec, client, options, cb) { options = {}; } - if(Array.isArray(assetSpec) && _.isString(options.acsCondMember)) { - assetSpec = client.acs.getConditionalValue(assetSpec, options.acsCondMember); + if(Array.isArray(assetSpec)) { + const acsCondMember = options.acsCondMember || 'art'; + assetSpec = client.acs.getConditionalValue(assetSpec, acsCondMember); } const artAsset = asset.getArtAsset(assetSpec); diff --git a/core/tic_file_info.js b/core/tic_file_info.js index 5db24cc2..fd3c7572 100644 --- a/core/tic_file_info.js +++ b/core/tic_file_info.js @@ -187,10 +187,10 @@ module.exports = class TicFileInfo { // send the file to be distributed and the accompanying TIC file. // Some File processors (Allfix) only insert a line with this // keyword when the file and the associated TIC file are to be - // file routed through a third sysem instead of being processed + // file routed through a third system instead of being processed // by a file processor on that system. Others always insert it. // Note that the To keyword may cause problems when the TIC file - // is proecessed by software that does not recognise it and + // is processed by software that does not recognize it and // passes the line "as is" to other systems. // // Example: To 292/854 diff --git a/core/user.js b/core/user.js index 6c2b964d..307dbacc 100644 --- a/core/user.js +++ b/core/user.js @@ -1,11 +1,18 @@ /* jslint node: true */ 'use strict'; +// ENiGMA½ const userDb = require('./database.js').dbs.user; const Config = require('./config.js').get; const userGroup = require('./user_group.js'); -const Errors = require('./enig_error.js').Errors; +const { + Errors, + ErrorReasons +} = require('./enig_error.js'); const Events = require('./events.js'); +const UserProps = require('./user_property.js'); +const Log = require('./logger.js').log; +const StatLog = require('./stat_log.js'); // deps const crypto = require('crypto'); @@ -39,18 +46,31 @@ module.exports = class User { static get StandardPropertyGroups() { return { - password : [ 'pw_pbkdf2_salt', 'pw_pbkdf2_dk' ], + password : [ UserProps.PassPbkdf2Salt, UserProps.PassPbkdf2Dk ], }; } static get AccountStatus() { return { - disabled : 0, - inactive : 1, - active : 2, + disabled : 0, // +op disabled + inactive : 1, // inactive, aka requires +op approval/activation + active : 2, // standard, active + locked : 3, // locked out (too many bad login attempts, etc.) }; } + static isSamePasswordSlowCompare(passBuf1, passBuf2) { + if(passBuf1.length !== passBuf2.length) { + return false; + } + + let c = 0; + for(let i = 0; i < passBuf1.length; i++) { + c |= passBuf1[i] ^ passBuf2[i]; + } + return 0 === c; + } + isAuthenticated() { return true === this.authenticated; } @@ -60,16 +80,21 @@ module.exports = class User { return false; } - return this.hasValidPassword(); + return this.hasValidPasswordProperties(); } - hasValidPassword() { - if(!this.properties || !this.properties.pw_pbkdf2_salt || !this.properties.pw_pbkdf2_dk) { + hasValidPasswordProperties() { + const salt = this.getProperty(UserProps.PassPbkdf2Salt); + const dk = this.getProperty(UserProps.PassPbkdf2Dk); + + if(!salt || !dk || + (salt.length !== User.PBKDF2.saltLen * 2) || + (dk.length !== User.PBKDF2.keyLen * 2)) + { return false; } - return ((this.properties.pw_pbkdf2_salt.length === User.PBKDF2.saltLen * 2) && - (this.properties.pw_pbkdf2_dk.length === User.PBKDF2.keyLen * 2)); + return true; } isRoot() { @@ -101,31 +126,85 @@ module.exports = class User { return 10; // :TODO: Is this what we want? } + processFailedLogin(userId, cb) { + async.waterfall( + [ + (callback) => { + return User.getUser(userId, callback); + }, + (tempUser, callback) => { + return StatLog.incrementUserStat( + tempUser, + UserProps.FailedLoginAttempts, + 1, + (err, failedAttempts) => { + return callback(null, tempUser, failedAttempts); + } + ); + }, + (tempUser, failedAttempts, callback) => { + const lockAccount = _.get(Config(), 'users.failedLogin.lockAccount'); + if(lockAccount > 0 && failedAttempts >= lockAccount) { + const props = { + [ UserProps.AccountStatus ] : User.AccountStatus.locked, + [ UserProps.AccountLockedTs ] : StatLog.now, + }; + if(!_.has(tempUser.properties, UserProps.AccountLockedPrevStatus)) { + props[UserProps.AccountLockedPrevStatus] = tempUser.getProperty(UserProps.AccountStatus); + } + Log.info( { userId, failedAttempts }, '(Re)setting account to locked due to failed logins'); + return tempUser.persistProperties(props, callback); + } + + return cb(null); + } + ], + err => { + return cb(err); + } + ); + } + + unlockAccount(cb) { + const prevStatus = this.getProperty(UserProps.AccountLockedPrevStatus); + if(!prevStatus) { + return cb(null); // nothing to do + } + + this.persistProperty(UserProps.AccountStatus, prevStatus, err => { + if(err) { + return cb(err); + } + + return this.removeProperties( [ UserProps.AccountLockedPrevStatus, UserProps.AccountLockedTs ], cb); + }); + } + authenticate(username, password, cb) { const self = this; - const cachedInfo = {}; + const tempAuthInfo = {}; async.waterfall( [ function fetchUserId(callback) { // get user ID User.getUserIdAndName(username, (err, uid, un) => { - cachedInfo.userId = uid; - cachedInfo.username = un; + tempAuthInfo.userId = uid; + tempAuthInfo.username = un; return callback(err); }); }, function getRequiredAuthProperties(callback) { // fetch properties required for authentication - User.loadProperties(cachedInfo.userId, { names : User.StandardPropertyGroups.password }, (err, props) => { + User.loadProperties(tempAuthInfo.userId, { names : User.StandardPropertyGroups.password }, (err, props) => { return callback(err, props); }); }, function getDkWithSalt(props, callback) { // get DK from stored salt and password provided - User.generatePasswordDerivedKey(password, props.pw_pbkdf2_salt, (err, dk) => { - return callback(err, dk, props.pw_pbkdf2_dk); + User.generatePasswordDerivedKey(password, props[UserProps.PassPbkdf2Salt], (err, dk) => { + return callback(err, dk, props[UserProps.PassPbkdf2Dk]); }); }, function validateAuth(passDk, propsDk, callback) { @@ -135,30 +214,57 @@ module.exports = class User { const passDkBuf = Buffer.from(passDk, 'hex'); const propsDkBuf = Buffer.from(propsDk, 'hex'); - if(passDkBuf.length !== propsDkBuf.length) { - return callback(Errors.AccessDenied('Invalid password')); - } - - let c = 0; - for(let i = 0; i < passDkBuf.length; i++) { - c |= passDkBuf[i] ^ propsDkBuf[i]; - } - - return callback(0 === c ? null : Errors.AccessDenied('Invalid password')); + return callback(User.isSamePasswordSlowCompare(passDkBuf, propsDkBuf) ? + null : + Errors.AccessDenied('Invalid password') + ); }, function initProps(callback) { - User.loadProperties(cachedInfo.userId, (err, allProps) => { + User.loadProperties(tempAuthInfo.userId, (err, allProps) => { if(!err) { - cachedInfo.properties = allProps; + tempAuthInfo.properties = allProps; } return callback(err); }); }, + function checkAccountStatus(callback) { + const accountStatus = parseInt(tempAuthInfo.properties[UserProps.AccountStatus], 10); + if(User.AccountStatus.disabled === accountStatus) { + return callback(Errors.AccessDenied('Account disabled', ErrorReasons.Disabled)); + } + if(User.AccountStatus.inactive === accountStatus) { + return callback(Errors.AccessDenied('Account inactive', ErrorReasons.Inactive)); + } + + if(User.AccountStatus.locked === accountStatus) { + const autoUnlockMinutes = _.get(Config(), 'users.failedLogin.autoUnlockMinutes'); + const lockedTs = moment(tempAuthInfo.properties[UserProps.AccountLockedTs]); + if(autoUnlockMinutes && lockedTs.isValid()) { + const minutesSinceLocked = moment().diff(lockedTs, 'minutes'); + if(minutesSinceLocked >= autoUnlockMinutes) { + // allow the login - we will clear any lock there + Log.info( + { username, userId : tempAuthInfo.userId, lockedAt : lockedTs.format() }, + 'Locked account will now be unlocked due to auto-unlock minutes policy' + ); + return callback(null); + } + } + return callback(Errors.AccessDenied('Account is locked', ErrorReasons.Locked)); + } + + // anything else besides active is still not allowed + if(User.AccountStatus.active !== accountStatus) { + return callback(Errors.AccessDenied('Account is not active')); + } + + return callback(null); + }, function initGroups(callback) { - userGroup.getGroupsForUser(cachedInfo.userId, (err, groups) => { + userGroup.getGroupsForUser(tempAuthInfo.userId, (err, groups) => { if(!err) { - cachedInfo.groups = groups; + tempAuthInfo.groups = groups; } return callback(err); @@ -166,15 +272,44 @@ module.exports = class User { } ], err => { - if(!err) { - self.userId = cachedInfo.userId; - self.username = cachedInfo.username; - self.properties = cachedInfo.properties; - self.groups = cachedInfo.groups; + if(err) { + // + // If we failed login due to something besides an inactive or disabled account, + // we need to update failure status and possibly lock the account. + // + // If locked already, update the lock timestamp -- ie, extend the lockout period. + // + if(![ErrorReasons.Disabled, ErrorReasons.Inactive].includes(err.reasonCode) && tempAuthInfo.userId) { + self.processFailedLogin(tempAuthInfo.userId, persistErr => { + if(persistErr) { + Log.warn( { error : persistErr.message }, 'Failed to persist failed login information'); + } + return cb(err); // pass along original error + }); + } else { + return cb(err); + } + } else { + // everything checks out - load up info + self.userId = tempAuthInfo.userId; + self.username = tempAuthInfo.username; + self.properties = tempAuthInfo.properties; + self.groups = tempAuthInfo.groups; self.authenticated = true; - } - return cb(err); + self.removeProperty(UserProps.FailedLoginAttempts); + + // + // We need to *revert* any locked status back to + // the user's previous status & clean up props. + // + self.unlockAccount(unlockErr => { + if(unlockErr) { + Log.warn( { error : unlockErr.message }, 'Failed to unlock account'); + } + return cb(null); + }); + } } ); } @@ -190,7 +325,7 @@ module.exports = class User { const self = this; // :TODO: set various defaults, e.g. default activation status, etc. - self.properties.account_status = config.users.requireActivation ? User.AccountStatus.inactive : User.AccountStatus.active; + self.properties[UserProps.AccountStatus] = config.users.requireActivation ? User.AccountStatus.inactive : User.AccountStatus.active; async.waterfall( [ @@ -211,7 +346,7 @@ module.exports = class User { // Do not require activation for userId 1 (root/admin) if(User.RootUserID === self.userId) { - self.properties.account_status = User.AccountStatus.active; + self.properties[UserProps.AccountStatus] = User.AccountStatus.active; } return callback(null, trans); @@ -224,8 +359,8 @@ module.exports = class User { return callback(err); } - self.properties.pw_pbkdf2_salt = info.salt; - self.properties.pw_pbkdf2_dk = info.dk; + self.properties[UserProps.PassPbkdf2Salt] = info.salt; + self.properties[UserProps.PassPbkdf2Dk] = info.dk; return callback(null, trans); }); }, @@ -289,20 +424,32 @@ module.exports = class User { ); } + static persistPropertyByUserId(userId, propName, propValue, cb) { + userDb.run( + `REPLACE INTO user_property (user_id, prop_name, prop_value) + VALUES (?, ?, ?);`, + [ userId, propName, propValue ], + err => { + if(cb) { + return cb(err, propValue); + } + } + ); + } + + getProperty(propName) { + return this.properties[propName]; + } + + getPropertyAsNumber(propName) { + return parseInt(this.getProperty(propName), 10); + } + persistProperty(propName, propValue, cb) { // update live props this.properties[propName] = propValue; - userDb.run( - `REPLACE INTO user_property (user_id, prop_name, prop_value) - VALUES (?, ?, ?);`, - [ this.userId, propName, propValue ], - err => { - if(cb) { - return cb(err); - } - } - ); + return User.persistPropertyByUserId(this.userId, propName, propValue, cb); } removeProperty(propName, cb) { @@ -321,6 +468,15 @@ module.exports = class User { ); } + removeProperties(propNames, cb) { + async.each(propNames, (name, next) => { + return this.removeProperty(name, next); + }, + err => { + return cb(err); + }); + } + persistProperties(properties, transOrDb, cb) { if(!_.isFunction(cb) && _.isFunction(transOrDb)) { cb = transOrDb; @@ -360,8 +516,8 @@ module.exports = class User { } const newProperties = { - pw_pbkdf2_salt : info.salt, - pw_pbkdf2_dk : info.dk, + [ UserProps.PassPbkdf2Salt ] : info.salt, + [ UserProps.PassPbkdf2Dk ] : info.dk, }; this.persistProperties(newProperties, err => { @@ -371,8 +527,9 @@ module.exports = class User { } getAge() { - if(_.has(this.properties, 'birthdate')) { - return moment().diff(this.properties.birthdate, 'years'); + const birthdate = this.getProperty(UserProps.Birthdate); + if(birthdate) { + return moment().diff(birthdate, 'years'); } } @@ -439,7 +596,7 @@ module.exports = class User { WHERE id = ( SELECT user_id FROM user_property - WHERE prop_name='real_name' AND prop_value LIKE ? + WHERE prop_name='${UserProps.RealName}' AND prop_value LIKE ? );`, [ realName ], (err, row) => { diff --git a/core/user_config.js b/core/user_config.js index f9aad6c4..d2748c4b 100644 --- a/core/user_config.js +++ b/core/user_config.js @@ -1,11 +1,17 @@ /* jslint node: true */ 'use strict'; +// ENiGMA½ const MenuModule = require('./menu_module.js').MenuModule; const ViewController = require('./view_controller.js').ViewController; const theme = require('./theme.js'); const sysValidate = require('./system_view_validate.js'); +const UserProps = require('./user_property.js'); +const { + getISOTimestampString +} = require('./database.js'); +// deps const async = require('async'); const assert = require('assert'); const _ = require('lodash'); @@ -49,7 +55,7 @@ exports.getModule = class UserConfigModule extends MenuModule { // // If nothing changed, we know it's OK // - if(self.client.user.properties.email_address.toLowerCase() === data.toLowerCase()) { + if(self.client.user.properties[UserProps.EmailAddress].toLowerCase() === data.toLowerCase()) { return cb(null); } @@ -101,15 +107,15 @@ exports.getModule = class UserConfigModule extends MenuModule { assert(formData.value.password === formData.value.passwordConfirm); const newProperties = { - real_name : formData.value.realName, - birthdate : new Date(Date.parse(formData.value.birthdate)).toISOString(), - sex : formData.value.sex, - location : formData.value.location, - affiliation : formData.value.affils, - email_address : formData.value.email, - web_address : formData.value.web, - term_height : formData.value.termHeight.toString(), - theme_id : self.availThemeInfo[formData.value.theme].themeId, + [ UserProps.RealName ] : formData.value.realName, + [ UserProps.Birthdate ] : getISOTimestampString(formData.value.birthdate), + [ UserProps.Sex ] : formData.value.sex, + [ UserProps.Location ] : formData.value.location, + [ UserProps.Affiliations ] : formData.value.affils, + [ UserProps.EmailAddress ] : formData.value.email, + [ UserProps.WebAddress ] : formData.value.web, + [ UserProps.TermHeight ] : formData.value.termHeight.toString(), + [ UserProps.ThemeId ] : self.availThemeInfo[formData.value.theme].themeId, }; // runtime set theme @@ -176,22 +182,22 @@ exports.getModule = class UserConfigModule extends MenuModule { }), 'name'); currentThemeIdIndex = Math.max(0, _.findIndex(self.availThemeInfo, function cmp(ti) { - return ti.themeId === self.client.user.properties.theme_id; + return ti.themeId === self.client.user.properties[UserProps.ThemeId]; })); callback(null); }, function populateViews(callback) { - var user = self.client.user; + const user = self.client.user; - self.setViewText('menu', MciCodeIds.RealName, user.properties.real_name); - self.setViewText('menu', MciCodeIds.BirthDate, moment(user.properties.birthdate).format('YYYYMMDD')); - self.setViewText('menu', MciCodeIds.Sex, user.properties.sex); - self.setViewText('menu', MciCodeIds.Loc, user.properties.location); - self.setViewText('menu', MciCodeIds.Affils, user.properties.affiliation); - self.setViewText('menu', MciCodeIds.Email, user.properties.email_address); - self.setViewText('menu', MciCodeIds.Web, user.properties.web_address); - self.setViewText('menu', MciCodeIds.TermHeight, user.properties.term_height.toString()); + self.setViewText('menu', MciCodeIds.RealName, user.properties[UserProps.RealName]); + self.setViewText('menu', MciCodeIds.BirthDate, moment(user.properties[UserProps.Birthdate]).format('YYYYMMDD')); + self.setViewText('menu', MciCodeIds.Sex, user.properties[UserProps.Sex]); + self.setViewText('menu', MciCodeIds.Loc, user.properties[UserProps.Location]); + self.setViewText('menu', MciCodeIds.Affils, user.properties[UserProps.Affiliations]); + self.setViewText('menu', MciCodeIds.Email, user.properties[UserProps.EmailAddress]); + self.setViewText('menu', MciCodeIds.Web, user.properties[UserProps.WebAddress]); + self.setViewText('menu', MciCodeIds.TermHeight, user.properties[UserProps.TermHeight].toString()); var themeView = self.getView(MciCodeIds.Theme); diff --git a/core/user_list.js b/core/user_list.js index 6f005432..3b342fab 100644 --- a/core/user_list.js +++ b/core/user_list.js @@ -5,6 +5,7 @@ const { MenuModule } = require('./menu_module.js'); const { getUserList } = require('./user.js'); const { Errors } = require('./enig_error.js'); +const UserProps = require('./user_property.js'); // deps const moment = require('moment'); @@ -44,7 +45,7 @@ exports.getModule = class UserListModule extends MenuModule { } const fetchOpts = { - properties : [ 'real_name', 'location', 'affiliation', 'last_login_timestamp' ], + properties : [ UserProps.RealName, UserProps.Location, UserProps.Affiliations, UserProps.LastLoginTs ], propsCamelCase : true, // e.g. real_name -> realName }; getUserList(fetchOpts, (err, userList) => { diff --git a/core/user_login.js b/core/user_login.js index a3b2089b..a46be5b1 100644 --- a/core/user_login.js +++ b/core/user_login.js @@ -8,34 +8,44 @@ const StatLog = require('./stat_log.js'); const logger = require('./logger.js'); const Events = require('./events.js'); const Config = require('./config.js').get; +const { + Errors, + ErrorReasons +} = require('./enig_error.js'); +const UserProps = require('./user_property.js'); // deps const async = require('async'); +const _ = require('lodash'); exports.userLogin = userLogin; function userLogin(client, username, password, cb) { - client.user.authenticate(username, password, function authenticated(err) { + client.user.authenticate(username, password, err => { + const config = Config(); + if(err) { + client.user.sessionFailedLoginAttempts = _.get(client.user, 'sessionFailedLoginAttempts', 0) + 1; + const disconnect = config.users.failedLogin.disconnect; + if(disconnect > 0 && client.user.sessionFailedLoginAttempts >= disconnect) { + err = Errors.BadLogin('To many failed login attempts', ErrorReasons.TooMany); + } + client.log.info( { username : username, error : err.message }, 'Failed login attempt'); - - // :TODO: if username exists, record failed login attempt to properties - // :TODO: check Config max failed logon attempts/etc. - set err.maxAttempts = true - return cb(err); } - const user = client.user; + + const user = client.user; + + // Good login; reset any failed attempts + delete user.sessionFailedLoginAttempts; // // Ensure this user is not already logged in. - // Loop through active connections -- which includes the current -- - // and check for matching user ID. If the count is > 1, disallow. // - let existingClientConnection; - clientConnections.forEach(function connEntry(cc) { - if(cc.user !== user && cc.user.userId === user.userId) { - existingClientConnection = cc; - } + const existingClientConnection = clientConnections.find(cc => { + return user !== cc.user && // not current connection + user.userId === cc.user.userId; // ...but same user }); if(existingClientConnection) { @@ -48,12 +58,10 @@ function userLogin(client, username, password, cb) { 'Already logged in' ); - const existingConnError = new Error('Already logged in as supplied user'); - existingConnError.existingConn = true; - - // :TODO: We should use EnigError & pass existing connection as second param - - return cb(existingConnError); + return cb(Errors.BadLogin( + `User ${user.username} already logged in.`, + ErrorReasons.AlreadyLoggedIn + )); } // update client logger with addition of username @@ -67,24 +75,24 @@ function userLogin(client, username, password, cb) { client.log.info('Successful login'); // User's unique session identifier is the same as the connection itself - user.sessionId = client.session.uniqueId; // convienence + user.sessionId = client.session.uniqueId; // convenience Events.emit(Events.getSystemEvents().UserLogin, { user } ); async.parallel( [ function setTheme(callback) { - setClientTheme(client, user.properties.theme_id); + setClientTheme(client, user.properties[UserProps.ThemeId]); return callback(null); }, function updateSystemLoginCount(callback) { - return StatLog.incrementSystemStat('login_count', 1, callback); + return StatLog.incrementSystemStat('login_count', 1, callback); // :TODO: create system_property.js }, function recordLastLogin(callback) { - return StatLog.setUserStat(user, 'last_login_timestamp', StatLog.now, callback); + return StatLog.setUserStat(user, UserProps.LastLoginTs, StatLog.now, callback); }, function updateUserLoginCount(callback) { - return StatLog.incrementUserStat(user, 'login_count', 1, callback); + return StatLog.incrementUserStat(user, UserProps.LoginCount, 1, callback); }, function recordLoginHistory(callback) { const loginHistoryMax = Config().statLog.systemEvents.loginHistoryMax; diff --git a/core/user_property.js b/core/user_property.js new file mode 100644 index 00000000..7f2bf6c5 --- /dev/null +++ b/core/user_property.js @@ -0,0 +1,53 @@ +/* jslint node: true */ +'use strict'; + +// +// Common user properties used throughout the system. +// +// This IS NOT a full list. For example, custom modules +// can utilize their own properties as well! +// +module.exports = { + PassPbkdf2Salt : 'pw_pbkdf2_salt', + PassPbkdf2Dk : 'pw_pbkdf2_dk', + + AccountStatus : 'account_status', // See User.AccountStatus enum + + RealName : 'real_name', + Sex : 'sex', + Birthdate : 'birthdate', + Location : 'location', + Affiliations : 'affiliation', + EmailAddress : 'email_address', + WebAddress : 'web_address', + TermHeight : 'term_height', + TermWidth : 'term_width', + ThemeId : 'theme_id', + AccountCreated : 'account_created', + LastLoginTs : 'last_login_timestamp', + LoginCount : 'login_count', + UserComment : 'user_comment', // NYI + + DownloadQueue : 'dl_queue', // download_queue.js + + FailedLoginAttempts : 'failed_login_attempts', + AccountLockedTs : 'account_locked_timestamp', + AccountLockedPrevStatus : 'account_locked_prev_status', // previous account status before lock out + + EmailPwResetToken : 'email_password_reset_token', + EmailPwResetTokenTs : 'email_password_reset_token_ts', + + FileAreaTag : 'file_area_tag', + FileBaseFilters : 'file_base_filters', + FileBaseFilterActiveUuid : 'file_base_filter_active_uuid', + FileBaseLastViewedId : 'user_file_base_last_viewed', + FileDlTotalCount : 'dl_total_count', + FileUlTotalCount : 'ul_total_count', + FileDlTotalBytes : 'dl_total_bytes', + FileUlTotalBytes : 'ul_total_bytes', + + MessageConfTag : 'message_conf_tag', + MessageAreaTag : 'message_area_tag', + MessagePostCount : 'post_count', +}; + diff --git a/core/web_password_reset.js b/core/web_password_reset.js index 6ca916da..90c5f57c 100644 --- a/core/web_password_reset.js +++ b/core/web_password_reset.js @@ -10,6 +10,7 @@ const User = require('./user.js'); const userDb = require('./database.js').dbs.user; const getISOTimestampString = require('./database.js').getISOTimestampString; const Log = require('./logger.js').log; +const UserProps = require('./user_property.js'); // deps const async = require('async'); @@ -17,6 +18,7 @@ const crypto = require('crypto'); const fs = require('graceful-fs'); const url = require('url'); const querystring = require('querystring'); +const _ = require('lodash'); const PW_RESET_EMAIL_TEXT_TEMPLATE_DEFAULT = `%USERNAME%: @@ -57,7 +59,7 @@ class WebPasswordReset { } User.getUser(userId, (err, user) => { - if(err || !user.properties.email_address) { + if(err || !user.properties[UserProps.EmailAddress]) { return callback(Errors.DoesNotExist('No email address associated with this user')); } @@ -77,8 +79,8 @@ class WebPasswordReset { token = token.toString('hex'); const newProperties = { - email_password_reset_token : token, - email_password_reset_token_ts : getISOTimestampString(), + [ UserProps.EmailPwResetToken ] : token, + [ UserProps.EmailPwResetTokenTs ] : getISOTimestampString(), }; // we simply place the reset token in the user's properties @@ -103,13 +105,13 @@ class WebPasswordReset { function buildAndSendEmail(user, textTemplate, htmlTemplate, callback) { const sendMail = require('./email.js').sendMail; - const resetUrl = webServer.instance.buildUrl(`/reset_password?token=${user.properties.email_password_reset_token}`); + const resetUrl = webServer.instance.buildUrl(`/reset_password?token=${user.properties[UserProps.EmailPwResetToken]}`); function replaceTokens(s) { return s .replace(/%BOARDNAME%/g, Config().general.boardName) .replace(/%USERNAME%/g, user.username) - .replace(/%TOKEN%/g, user.properties.email_password_reset_token) + .replace(/%TOKEN%/g, user.properties[UserProps.EmailPwResetToken]) .replace(/%RESET_URL%/g, resetUrl) ; } @@ -120,7 +122,7 @@ class WebPasswordReset { } const message = { - to : `${user.properties.display_name||user.username} <${user.properties.email_address}>`, + to : `${user.properties[UserProps.RealName]||user.username} <${user.properties[UserProps.EmailAddress]}>`, // from will be filled in subject : 'Forgot Password', text : textTemplate, @@ -283,8 +285,15 @@ class WebPasswordReset { } // delete assoc properties - no need to wait for completion - user.removeProperty('email_password_reset_token'); - user.removeProperty('email_password_reset_token_ts'); + user.removeProperties([ UserProps.EmailPwResetToken, UserProps.EmailPwResetTokenTs ]); + + if(true === _.get(config, 'users.unlockAtEmailPwReset')) { + Log.info( + { username : user.username, userId : user.userId }, + 'Remove any lock on account due to password reset policy' + ); + user.unlockAccount( () => { /* dummy */ } ); + } resp.writeHead(200); return resp.end('Password changed successfully'); diff --git a/docs/_includes/nav.md b/docs/_includes/nav.md index c8cb67c9..84785dbe 100644 --- a/docs/_includes/nav.md +++ b/docs/_includes/nav.md @@ -14,15 +14,17 @@ - [Creating Config Files]({{ site.baseurl }}{% link configuration/creating-config.md %}) - [SysOp Setup]({{ site.baseurl }}{% link configuration/sysop-setup.md %}) - [Editing hjson]({{ site.baseurl }}{% link configuration/editing-hjson.md %}) - - [config.hjson]({{ site.baseurl }}{% link configuration/config-hjson.md %}) - - [menu.hjson]({{ site.baseurl }}{% link configuration/menu-hjson.md %}) - - [prompt.hjson]({{ site.baseurl }}{% link configuration/prompt-hjson.md %}) + - [System Configuration]({{ site.baseurl }}{% link configuration/config-hjson.md %}) + - [HJSON General]({{ site.baseurl }}{% link configuration/hjson.md %}) + - [Menus]({{ site.baseurl }}{% link configuration/menu-hjson.md %}) + - [Prompts]({{ site.baseurl }}{% link configuration/prompt-hjson.md %}) - [Directory Structure]({{ site.baseurl }}{% link configuration/directory-structure.md %}) - [Archivers]({{ site.baseurl }}{% link configuration/archivers.md %}) - [File Transfer Protocols]({{ site.baseurl }}{% link configuration/file-transfer-protocols.md %}) - [Email]({{ site.baseurl }}{% link configuration/email.md %}) - [Colour Codes]({{ site.baseurl }}{% link configuration/colour-codes.md %}) - [Access Condition System (ACS)]({{ site.baseurl }}{% link configuration/acs.md %}) + - [Event Scheduler]({{ site.baseurl }}{% link configuration/event-scheduler.md %}) - Scheduled jobs - File Base @@ -73,11 +75,13 @@ - [Rumorz]({{ site.baseurl }}{% link modding/rumorz.md %}) - [File Transfer Protocol Select]({{ site.baseurl }}{% link modding/file-transfer-protocol-select.md %}) - [Onelinerz]({{ site.baseurl }}{% link modding/onelinerz.md %}) + - [Show Art]({{ site.baseurl }}{% link modding/show-art.md %}) + - [Download Manager]({{ site.baseurl }}{% link modding/file-base-download-manager.md %}) + - [Web Download Manager]({{ site.baseurl }}{% link modding/file-base-web-download-manager.md %}) + - [Set Newscan Date]({{ site.baseurl }}{% link modding/set-newscan-date.md %}) - Administration - [oputil]({{ site.baseurl }}{% link admin/oputil.md %}) - - - [Oputil]({{ site.baseurl }}{% link oputil/index.md %}) - Troubleshooting - [Monitoring Logs]({{ site.baseurl }}{% link troubleshooting/monitoring-logs.md %}) diff --git a/docs/admin/oputil.md b/docs/admin/oputil.md index 8fbf0a46..9db1439c 100644 --- a/docs/admin/oputil.md +++ b/docs/admin/oputil.md @@ -27,7 +27,7 @@ Commands break up operations by groups: | Command | Description | |-----------|---------------| | `user` | User management | -| `config` | System configuration and maintentance | +| `config` | System configuration and maintenance | | `fb` | File base configuration and management | | `mb` | Message base configuration and management | @@ -45,11 +45,12 @@ usage: optutil.js user [] actions: pw USERNAME PASSWORD set password to PASSWORD for USERNAME - rm USERNAME permanantely removes USERNAME user from system + rm USERNAME permanently removes USERNAME user from system activate USERNAME sets USERNAME's status to active - deactivate USERNAME sets USERNAME's status to deactive + deactivate USERNAME sets USERNAME's status to inactive disable USERNAME sets USERNAME's status to disabled - group USERNAME [+|-]GROUP adds (+) or removes (-) USERNAME from GROUP + lock USERNAME sets USERNAME's status to locked + group USERNAME [+|-]GROUP adds (+) or removes (-) user from GROUP ``` | Action | Description | Examples | Aliases | @@ -59,6 +60,7 @@ actions: | `activate` | Activates user | `./oputil.js user activate joeuser` | N/A | | `deactivate` | Deactivates user | `./oputil.js user deactivate joeuser` | N/A | | `disable` | Disables user (user will not be able to login) | `./oputil.js user disable joeuser` | N/A | +| `lock` | Locks the user account (prevents logins) | `./oputil.js user lock joeuser` | N/A | | `group` | Modifies users group membership | Add to group: `./oputil.js user group joeuser +derp`
Remove from group: `./oputil.js user group joeuser -derp` | N/A | ## Configuration @@ -82,7 +84,7 @@ import-areas args: | Action | Description | Examples | |-----------|-------------------|---------------------------------------| | `new` | Generates a new/initial configuration | `./oputil.js config new` (follow the prompts) | -| `import-areas` | Imports areas using a Fidonet style *.NA or AREAS.BBS formatted file | `./oputil.js config import-areas /some/path/l33tnet.na` | +| `import-areas` | Imports areas using a FidoNet style *.NA or AREAS.BBS formatted file | `./oputil.js config import-areas /some/path/l33tnet.na` | When using the `import-areas` action, you will be prompted for any missing additional arguments described in "import-areas args". @@ -138,7 +140,7 @@ general information: The `scan` action can (re)scan a file area for new entries as well as update (`--update`) existing entry records (description, etc.). When scanning, a valid area tag must be specified. Optionally, storage tag may also be supplied in order to scan a specific filesystem location using the `@the_storage_tag` syntax. If a [GLOB](http://man7.org/linux/man-pages/man7/glob.7.html) is supplied as the last argument, only file entries with filenames matching will be processed. ##### Examples -Performing a quick scan of a specific area's storage location ("retro_warez", "retro_warez_games) matching only *.zip extentions: +Performing a quick scan of a specific area's storage location ("retro_warez", "retro_warez_games) matching only *.zip extensions: ``` $ ./oputil.js fb scan --quick retro_warez@retro_warez_games *.zip` ``` diff --git a/docs/art/general.md b/docs/art/general.md index f08994e8..a0620de3 100644 --- a/docs/art/general.md +++ b/docs/art/general.md @@ -1,5 +1,111 @@ --- layout: page -title: General +title: General Art Information --- -General art lives in the `art/general` directory. 'General' art is ANSI you want to stay consistent across themes, such as a welcome ANSI or a rotation of logoff ANSIs. +## General Art Information +One of the most basic elements of BBS customization is through it's artwork. ENiGMA½ supports a variety of ways to select, display, and manage art. + +As a general rule, art files live in one of two places: + +1. The `art/general` directory. This is where you place command non-themed art files. +2. Within a theme such as `art/themes/super_fancy_theme`. + +### Menu Entries +While art can be displayed programmatically such as from a custom module, the most basic and common form is via `menu.hjson` entries. This usually falls into one of two forms: a "standard" entry where a single `art` spec is utilized or a entry for a custom module where multiple pieces are declared and used. The second style usually takes the form of a `config.art` block with two or more entries. + +A menu entry has a few elements that control how art is choosen and displayed. First, the `art` *spec* tells teh system how to look for the art asset. Second, the `config` block can further control aspecs of lookup and display: + +| Item | Description| +|------|------------| +| `font` | Sets the [SyncTERM](http://syncterm.bbsdev.net/) style font to use when displaying this art. If unset, the system will use the art's embedded [SAUCE](http://www.acid.org/info/sauce/sauce.htm) record if present or simply use the current font. See Fonts below. | +| `pause` | If set to `true`, pause after displaying. | +| `baudRate` | Set a [SyncTERM](http://syncterm.bbsdev.net/) style emulated baud rate when displaying this art. In other words, slow down the display. | +| `cls` | Clear the screen before display if set to `true`. | +| `random` | Set to `false` to explicitly disable random lookup. | +| `types` | An optional array of types (aka file extensions) to consider for lookup. For example : `[ '.ans', '.asc' ]` | +| `readSauce` | May be set to `false` if you need to explictly disable SAUCE support. | + +#### Art Spec +It was mentioned that the `art` member is a *spec*. The value of a `art` member controls how the system looks for an asset. The following forms are supported: + +* `FOO`: The system will look for `FOO.ANS`, `FOO.ASC`, `FOO.TXT`, etc. using the default search path. Unless otherwise specified if `FOO1.ANS`, `FOO2.ANS`, and so on exist, a random selection will be made. +* `FOO.ANS`: By specifying an extension, only that type will be searched for. +* `rel/path/to/BAR.ANS`: Only match a path (relative to the system's `art` directory). +* `/path/to/BAZ.ANS`: Exact path only. + +ENiGMA½ uses a fallback system for art selection. When a menu entry calls for a piece of art, the following search is made: + +1. If a direct or relative path is supplied, look there first. +2. In the users current theme directory. +3. In the system default theme directory. +4. In the `art/general` directory. + +#### SyncTERM Style Fonts +ENiGMA½ can set a [SyncTERM](http://syncterm.bbsdev.net/) style font for art display. This is supported by many popular BBS terminals besides just SyncTERM and is common for displaying Amiga style fonts for example. The system will use the `font` specifier or look for a font declared in an artworks SAUCE record (unless `readSauce` is `false`). + +The most common fonts are probably as follows: + +* `cp437` +* `c64_upper` +* `c64_lower` +* `c128_upper` +* `c128_lower` +* `atari` +* `pot_noodle` +* `mo_soul` +* `microknight_plus` +* `topaz_plus` +* `microknight` +* `topaz` + +Other fonts fonts also available: +* `cp1251` +* `koi8_r` +* `iso8859_2` +* `iso8859_4` +* `cp866` +* `iso8859_9` +* `haik8` +* `iso8859_8` +* `koi8_u` +* `iso8859_15` +* `iso8859_4` +* `koi8_r_b` +* `iso8859_4` +* `iso8859_5` +* `ARMSCII_8` +* `iso8859_15` +* `cp850` +* `cp850` +* `cp885` +* `cp1251` +* `iso8859_7` +* `koi8-r_c` +* `iso8859_4` +* `iso8859_1` +* `cp866` +* `cp437` +* `cp866` +* `cp885` +* `cp866_u` +* `iso8859_1` +* `cp1131` + +See [this specification](https://github.com/protomouse/synchronet/blob/master/src/conio/cterm.txt) for more information. + +#### SyncTERM Style Baud Rates +The `baudRate` member can set a [SyncTERM](http://syncterm.bbsdev.net/) style emulated baud rate. May be `300`, `600`, `1200`, `2400`, `4800`, `9600`, `19200`, `38400`, `57600`, `76800`, or `115200`. A value of `ulimited`, `off`, or `0` resets (disables) the rate. See [this specification](https://github.com/protomouse/synchronet/blob/master/src/conio/cterm.txt) for more information. + +## Common Example +```hjson +fullLogoffSequenceRandomBoardAd: { + art: OTHRBBS + desc: Logging Off + next: logoff + config: { + baudRate: 57600 + pause: true + cls: true + } +} +``` \ No newline at end of file diff --git a/docs/art/mci.md b/docs/art/mci.md index f5876105..bc6bab5a 100644 --- a/docs/art/mci.md +++ b/docs/art/mci.md @@ -72,13 +72,14 @@ for a full listing. Many codes attempt to pay homage to Oblivion/2, iNiQUiTY, et | `SP` | Total uploaded amount, system wide (formatted to appropriate bytes/megs/etc.) | Some additional special case codes also exist: + | Code | Description | |--------|--------------| | `CF##` | Moves the cursor position forward _##_ characters | | `CB##` | Moves the cursor position back _##_ characters | | `CU##` | Moves the cursor position up _##_ characters | | `CD##` | Moves the cursor position down _##_ characters | -| `XY` | A special code that may be utilized for placement identification when creating menus or to extend an otherwise empty space in an art file down the screen. +| `XY` | A special code that may be utilized for placement identification when creating menus or to extend an otherwise empty space in an art file down the screen. | ## Views @@ -104,7 +105,7 @@ see additional information. ## Properties & Theming -Predefined MCI codes and other Views can have properties set via `menu.hjson` and further *themed* via `theme.hjson`. +Predefined MCI codes and other Views can have properties set via `menu.hjson` and further *themed* via `theme.hjson`. See [Themes](themes.md) for more information on this subject. ### Common Properties diff --git a/docs/art/themes.md b/docs/art/themes.md index 9ef482b6..577a95fe 100644 --- a/docs/art/themes.md +++ b/docs/art/themes.md @@ -2,28 +2,131 @@ layout: page title: Themes --- -# Creating Your Own -:warning: ***IMPORTANT!*** It is recommended you don't make any customisations to the included -`luciano_blocktronics' theme. Create your own and make changes to that instead: +## Themes +ENiGMA½ comes with an advanced theming system allowing system operators to highly customize the look and feel of their boards. A given installation can have as many themes as you like for your users to choose from. + +## General Information +Themes live in `art/themes/`. Each theme (and thus it's *theme ID*) is a directory within the `themes` directory. The theme itself is simply a collection of art files, and a `theme.hjson` file that further defines layout, colors & formatting, etc. ENiGMA½ comes with a default theme by [Luciano Ayres](http://blocktronics.org/tag/luciano-ayres/) of [Blocktronics](http://blocktronics.org/) called Mystery Skull. This theme is in `art/themes/luciano_blocktronics`, and thus it's *theme ID* is `luciano_blocktronics`. + +## Art +For information on art files, see [General Art Information](general.md). TL;DR: In general, to theme a piece of art, create a version of it in your themes directory. + +:information_source: Remember that by default, the system will allow for randomly selecting art (in one of the directories mentioned above) by numbering it: `FOO1.ANS`, `FOO2.ANS`, etc.! + +## Theme Sections +Themes are some important sections to be aware of: + +| Config Item | Description | +|-------------|----------------------------------------------------------| +| `info` | This section describes the theme. | +| `customization` | The beef! | + +### Info Block +The `info` configuration block describes the theme itself. + +| Item | Required | Description | +|-------------|----------|----------------------------------------------------------| +| `name` | :+1: | Name of the theme. Be creative! | +| `author` | :+1: | Author of the theme/artwork. | +| `group` | :-1: | Group/affils of author. | +| `enabled` | :-1: | Boolean of enabled state. If set to `false`, this theme will not be available to your users. If a user currently has this theme selected, the system default will be selected for them at next login. | + +### Customization Block +The `customization` block in is itself broken up into major parts: + +| Item | Description | +|-------------|---------------------------------------------------| +| `defaults` | Default values to use when this theme is active. These values override system defaults, but can still be overridden themselves in specific areas of your theme. | +| `menus` | The bulk of what you theme in the system will be here. Any menu (that is, anything you find in `menu.hjson`) can be tweaked. | +| `prompts` | Similar to `menus`, this file themes prompts found in `prompts.hjson`. | + +#### Defaults +| Item | Description | +|-------------|---------------------------------------------------| +| `passwordChar` | Character to display in password fields. Defaults to `*` | +| `dateFormat` | Sets the [moment.js](https://momentjs.com/docs/#/displaying/) style `short` and/or `long` format for dates. | +| `timeFormat` | Sets the [moment.js](https://momentjs.com/docs/#/displaying/) style `short` and/or `long` format for times. | +| `dateTimeFormat` | Sets the [moment.js](https://momentjs.com/docs/#/displaying/) style `short` and/or `long` format for date/time combinations. | + +Example: +```hjson +defaults: { + dateTimeFormat: { + short: MMM Do h:mm a + } +} +``` + +#### Menus Block +Each *key* in the `menus` block matches up with a key found in your `menu.hjson`. For example, consider a `matrix` menu defined in `menu.hjson`. In addition to perhaps providing a `MATRIX.ANS` in your themes directory, you can also theme other parts of the menu via a `matrix` entry in `theme.hjson`. + +Major areas to override/theme: +* `config`: Override and/or provide additional theme information over that found in the `menu.hjson`'s entry. Common entries here are for further overriding date/time formats, and custom range info formats (`InfoFormat`). See Entry Formatting in [MCI Codes](mci.md) and Custom Range Info Formatting below. +* `mci`: Set per-MCI code properties such as `height`, `width`, text styles, etc. See [MCI Codes](mci.md) for a more information. + +Two formats for `mci` blocks are allowed: +* Verbose where a form ID(s) are supplied. +* Shorthand if only a single/first form is needed. + +Example: Verbose `mci` with form IDs: +```hjson +newUserFeedbackToSysOp: { + 0: { + mci: { + TL1: { width: 19, textOverflow: "..." } + ET2: { width: 19, textOverflow: "..." } + ET3: { width: 19, textOverflow: "..." } + } + } + 1: { + mci: { + MT1: { height: 14 } + } + } +} +``` + +Example: Shorthand `mci` format: +```hjson +matrix: { + mci: { + VM1: { + itemFormat: "|03{text}" + focusItemFormat: "|11{text!styleFirstLower}" + } + } +} +``` + +##### Custom Range Info Formatting +Many modules support "custom range" MCI items. These are MCI codes that are left to the user to define using a format object specific to the module. For example, consider the `msg_area_list` module: This module sets MCI codes 10+ (`%TL10`, `%TL11`, etc.) as "custom range". When theming you can place these MCI codes in your artwork then define the format in `theme.hjson`: + +```hjson +messageAreaChangeCurrentArea: { + config: { + areaListInfoFormat10: "|15{name}|07: |03{desc}" + } +} +``` + +## Creating Your Own +:warning: ***IMPORTANT!*** It is recommended you don't make any customisations to the included `luciano_blocktronics' theme. Instead, create your own and make changes to that instead: 1. Copy `/art/themes/luciano_blocktronics` to `art/themes/your_board_theme` 2. Update the `info` block at the top of the theme.hjson file: ``` hjson info: { - name: Awesome Theme - author: Cool Artist - group: Sick Group - enabled: true + name: Awesome Theme + author: Cool Artist + group: Sick Group + enabled: true // default } ``` -3. Specify it in the `defaults` section of `config.hjson`. The name supplied should match the name of the -directory you created in step 1: - +3. If desired, you may make this the default system theme in `config.hjson` via `theme.default`. `theme.preLogin` may be set if you want this theme used for pre-authenticated users. Both of these values also accept `*` if you want the system to radomly pick. ``` hjson - defaults: { - theme: your_board_theme - } + theme: { + default: your_board_theme + preLogin: * + } ``` - -# General Theme Info \ No newline at end of file diff --git a/docs/configuration/acs.md b/docs/configuration/acs.md index eeab8e9c..dde55709 100644 --- a/docs/configuration/acs.md +++ b/docs/configuration/acs.md @@ -61,6 +61,7 @@ The following touch points exist in the system. Many more are planned: * Message conferences and areas * File base areas -* Menus within `menu.hjson`. See [menu.hjson](menu-hjson.md). +* Menus within `menu.hjson`. See [Menu HJSON](menu-hjson.md). + See the specific areas documentation for information on available ACS checks. diff --git a/docs/configuration/config-hjson.md b/docs/configuration/config-hjson.md index 37d07beb..7aae987f 100644 --- a/docs/configuration/config-hjson.md +++ b/docs/configuration/config-hjson.md @@ -1,12 +1,14 @@ --- layout: page -title: config.hjson +title: System Configuration --- ## System Configuration The main system configuration file, `config.hjson` both overrides defaults and provides additional configuration such as message areas. The default path is `/enigma-bbs-install-path/config/config.hjson` though you can override the `config.hjson` location with the `--config` parameter when invoking `main.js`. Values found in `core/config.js` may be overridden by simply providing the object members you wish replace. +See also [HJSON General Information](hjson.md) for more information on the HJSON format. + ### Creating a Configuration -Your initial configuration skeleton can be created using the `oputil.js` command line utility. From your enigma-bbs root directory: +Your initial configuration skeleton should be created using the `oputil.js` command line utility. From your enigma-bbs root directory: ``` ./oputil.js config new ``` @@ -28,10 +30,21 @@ general: { } ``` -(Note the very slightly different syntax. **You can use standard JSON if you wish**) +(Note the very slightly [HJSON](hjson.md) different syntax. **You can use standard JSON if you wish!**) While not everything that is available in your `config.hjson` file can be found defaulted in `core/config.js`, a lot is. [Poke around and see what you can find](https://github.com/NuSkooler/enigma-bbs/blob/master/core/config.js)! +### Configuration Sections +Below is a list of various configuration sections. There are many more, but this should get you started: + +* [ACS](acs.md) +* [Archivers](archivers.md): Set up external archive utilities for handling things like ZIP, ARJ, RAR, and so on. +* [Email](email.md): System email support. +* [Event Scheduler](event-scheduler.md): Set up events as you see fit! +* [File Base](/docs/filebase/index.md) +* [File Transfer Protocols](file-transfer-protocols.md): Oldschool file transfer protocols such as X/Y/Z-Modem! +* [Message Areas](/docs/messageareas/configuring-a-message-area.md), [Networks](/docs/messageareas/message-networks.md), [NetMail](/docs/messageareas/netmail.md), etc. + ### A Sample Configuration Below is a **sample** `config.hjson` illustrating various (but certainly not all!) elements that can be configured / tweaked. diff --git a/docs/configuration/creating-config.md b/docs/configuration/creating-config.md index c8495f1c..5d845d5e 100644 --- a/docs/configuration/creating-config.md +++ b/docs/configuration/creating-config.md @@ -2,26 +2,13 @@ layout: page title: Creating Initial Config Files --- -Configuration files in ENiGMA½ are simple UTF-8 encoded [HJSON](http://hjson.org/) files. HJSON is just -like JSON but simplified and much more resilient to human error. +Configuration files in ENiGMA½ are simple UTF-8 encoded [HJSON](http://hjson.org/) files. HJSON is just like JSON but simplified and much more resilient to human error. -## config.hjson -Your initial configuration skeleton can be created using the `oputil.js` command line utility. From your -enigma-bbs root directory: -``` +## Initial Configuration +Your initial configuration skeleton can be created using the `oputil.js` command line utility. From your enigma-bbs root directory: +```bash ./oputil.js config new ``` -You will be asked a series of questions to create an initial configuration. +You will be asked a series of questions to create an initial configuration, which will be saved to `/enigma-bbs-install-path/config/config.hjson`. This will also produce `config/-menu.hjson` and `config/-prompt.hjson` files (where `` is replaced by the name you provided in the steps above). See [Menu HJSON](menu-hjson.md) and [Prompt HJSON](prompt-hjson.md) for more information. -## menu.hjson and prompt.hjson - -Create your own copy of `/config/menu.hjson` and `/config/prompt.hjson`, and specify it in the -`general` section of `config.hjson`: - -````hjson -general: { - menuFile: my-menu.hjson - promptFile: my-prompt.hjson -} -```` \ No newline at end of file diff --git a/docs/configuration/email.md b/docs/configuration/email.md index 5bc4d4c8..eb13ef71 100644 --- a/docs/configuration/email.md +++ b/docs/configuration/email.md @@ -2,16 +2,18 @@ layout: page title: Email --- -ENiGMA½ uses email to send password reset information to users. For it to work, you need to provide valid SMTP -config in your [config.hjson]({{ site.baseurl }}{% link configuration/config-hjson.md %}) +## Email Support +ENiGMA½ uses email to send password reset information to users. For it to work, you need to provide valid [Nodemailer](https://nodemailer.com/about/) compatible `email` block in your [config.hjson]({{ site.baseurl }}{% link configuration/config-hjson.md %}). Nodemailer supports SMTP in addition to many pre-defined services for ease of use. The `transport` block within `email` must be Nodemailer compatible. -## SMTP Services +Additional email support will come in the near future. -If you don't have an SMTP server to send from, [Sendgrid](https://sendgrid.com/) provide a reliable free -service. +## Services -## Example SMTP Configuration +If you don't have an SMTP server to send from, [Sendgrid](https://sendgrid.com/) and [Zoho](https://www.zoho.com/mail/) both provide reliable and free services. +## Example Configurations + +Example 1 - SMTP: ```hjson email: { defaultFrom: sysop@bbs.awesome.com @@ -27,3 +29,21 @@ email: { } } ``` + +Example 2 - Zoho +```hjson +email: { + defaultFrom: sysop@bbs.awesome.com + + transport: { + service: Zoho + auth: { + user: noreply@bbs.awesome.com + pass: yuspymypass + } + } +} +``` + +## Lockout Reset +If email is available on your system and you allow email-driven password resets, you may elect to allow unlocking accounts at the time of a password reset. This is controlled by the `users.unlockAtEmailPwReset` configuration option. If an account is locked due to too many failed login attempts, a user may reset their password to remedy the situation themselves. diff --git a/docs/configuration/event-scheduler.md b/docs/configuration/event-scheduler.md new file mode 100644 index 00000000..77b56f15 --- /dev/null +++ b/docs/configuration/event-scheduler.md @@ -0,0 +1,79 @@ +--- +layout: page +title: Event Scheduler +--- +## Event Scheduler +The ENiGMA½ scheduler allows system operators to configure arbitrary events that can can fire based on date and/or time, or by watching for changes in a file. Events can kick off internal handlers, custom modules, or binaries & scripts. + +## Scheduling Events +To create a scheduled event, create a new configuration block in `config.hjson` under `eventScheduler.events`. + +Events can have the following members: + +| Item | Required | Description | +|------|----------|-------------| +| `schedule` | :+1: | A [Later style](https://bunkat.github.io/later/parsers.html#text) parsable schedule string such as `at 4:00 am`, or `every 24 hours`. Can also be (or contain) an `@watch` clause. See **Schedules** below for details. | +| `action` | :+1: | Action to perform when the schedule is triggered. May be an `@method` or `@execute` spec. See **Actions** below. | +| `args` | :-1: | An array of arguments to pass along to the method or binary specified in `action`. | + +### Schedules +As mentioned above, `schedule` may contain a [Later style](https://bunkat.github.io/later/parsers.html#text) parsable schedule string and/or an `@watch` clause. + +`schedule` examples: +* `every 2 hours` +* `on the last day of the week` +* `after 12th hour` + +An `@watch` clause monitors a specified file for changes and takes the following form: `@watch:` where `` is a fully qualified path. + +:information_source: If you would like to have a schedule **and** watch a file for changes, place the `@watch` clause second and seperated with the word `or`. For example: `every 24 hours or @watch:/path/to/somefile.txt`. + +### Actions +Events can kick off actions by calling a method (function) provided by the system or custom module in addition to executing arbritary binaries or scripts. + +#### Methods +An action with a `@method` can take the following forms: + +* `@method:/full/path/to/module.js:methodName`: Executes `methodName` at `/full/path/to/module.js`. +* `@method:rel/path/to/module.js:methodName`: Executes `methodName` using the *relative* path `rel/path/to/module.js`. Paths for `@method` are relative to the ENiGMA½ installation directory. + +Methods are passed any supplied `args` in the order they are provided. + +##### Method Signature +To create your own method, simply `export` a method with the following signature: `(args, callback)`. Methods are executed asynchronously. + +Example: +```javascript +// my_custom_mod.js +exports.myCustomMethod = (args, cb) => { + console.log(`Hello, ${args[0]}!`); + return cb(null); +} +``` + +#### Executables +When using the `@execute` action, a binary or script can be executed. A full path or just the binary name is acceptable. If using the form without a path, the binary much be in ENiGMA½'s `PATH`. + +Examples: +* `@execute:/usr/bin/foo` +* `@execute:foo` + +Just like with methods, any supplied `args` will be passed along. + +## Example Entries + +Post a message to supplied networks every Monday night using the message post mod (see modding): +```hjson +eventScheduler: { + events: { + enigmaAdToNetworks: { + schedule: at 10:35 pm on Mon + action: @method:mods/message_post_evt/message_post_evt.js:messagePostEvent + args: [ + "fsx_bot" + "/home/enigma-bbs/ad.asc" + ] + } + } +} +``` \ No newline at end of file diff --git a/docs/configuration/hjson.md b/docs/configuration/hjson.md new file mode 100644 index 00000000..cc8fa26d --- /dev/null +++ b/docs/configuration/hjson.md @@ -0,0 +1,69 @@ +--- +layout: page +title: HJSON General Information +--- +## JSON for Humans! +HJSON is the configuration file format used by ENiGMA½ for [System Configuration](config-hjson.md), [Menus](menu-hjson.md), [Prompts](prompt-hjson.md), etc. [HJSON](https://hjson.org/) is is [JSON](https://json.org/) for humans! + +For those completely unfamiliar, JSON stands for JavaScript Object Notation. But don't let that scare you! JSON is simply a text file format with a bit of structure ― kind of like a fancier INI file. HJSON on the other hand as mentioned previously, is JSON for humans. That is, it has the following features and more: + +* More resilient to syntax errors such as missing a comma +* Strings generally do not need to be quoted. Multi-line strings are also supported! +* Comments are supported (JSON doesn't allow this!): `#`, `//` and `/* ... */` style comments are allowed. +* Keys never need to be quoted +* ...much more! See [the official HJSON website](https://hjson.org/). + +## Terminology +Through the documentation, some terms regarding HJSON and configuration files will be used: + +* `config.hjson`: Refers to `/path/to/enigma-bbs/config/config.hjson`. See [System Configuration](config-hjson.md). +* `menu.hjson`: Refers to `/path/to/enigma-bbs/config/-menu.hjson`. See [Menus](menu-hjson.md). +* `prompt.hjson`: Refers to `/path/to/enigma-bbs/config/-prompt.hjson`. See [Prompts](prompt-hjson.md). +* Configuration *key*: Elements in HJSON are name-value pairs where the name is the *key*. For example, provided `foo: bar`, `foo` is the key. +* Configuration *section* or *block* (also commonly called an "Object" in code): This is referring to a section in a HJSON file that starts with a *key*. For example: +```hjson +someSection: { + foo: bar +} +``` +Note that `someSection` is the configuration *section* (or *block*) and `foo: bar` is within it. + +## Editing HJSON +HJSON is a text file format, and ENiGMA½ configuration files **should always be saved as UTF-8**. + +It is **highly** recommended to use a text editor that has HJSON support. A few (but not all!) examples include: +* Sublime Text 3 via the `sublime-hjson` package. +* Visual Studio code via the `vscode-hjson` plugin. +* Notepad++ via the `npp-hjson` plugin. + +See https://hjson.org/users.html for more more editors & plugins. + +### Hot-Reload A.K.A. Live Editing +ENiGMA½'s configuration, menu, and theme files can edited while your BBS is running. When a file is saved, it is hot-reloaded into the running system. If users are currently connected and you change a menu for example, the next reload of that menu will show the changes. + +### CaSe SeNsiTiVE +Configuration keys are **case sensitive**. That means if a configuration key is `boardName` for example, `boardname`, or `BOARDNAME` **will not work**. + +### Escaping +Some values need escaped. This is especially important to remember on Windows machines where file paths contain backslashes (`\`). To specify a path to `C:\foo\bar\baz.exe` for example, an entry may look like this in your configuration file: +```hjson +something: { + path: "C:\\foo\\bar\\baz.exe" // note the extra \'s! +} +``` + +## Tips & Tricks +### JSON Compatibility +Remember that standard JSON is fully compatible with HJSON. If you are more comfortable with JSON (or have an editor that works with JSON that you prefer) simply convert your config file(s) to JSON and use that instead! + +HJSON can be converted to JSON with the `hjson` CLI: +```bash +cd /path/to/enigma-bbs +cp ./config/config.hjson ./config/config.hjson.backup +./node_modules/hjson/bin/hjson ./config/config.hjson.backup -j > ./config/config.hjson +``` + +You can always convert back to HJSON by omitting `-j` in the command above. + +### oputil +You can easily dump out your current configuration in a pretty-printed style using oputil: ```./oputil.js config cat``` diff --git a/docs/configuration/menu-hjson.md b/docs/configuration/menu-hjson.md index 7d1bbed4..4d8dec9b 100644 --- a/docs/configuration/menu-hjson.md +++ b/docs/configuration/menu-hjson.md @@ -1,20 +1,11 @@ --- layout: page -title: menu.hjson +title: Menus --- -:warning: ***IMPORTANT!*** Before making any customisations, create your own copy of `/config/menu.hjson`, and specify it in the `general` section of `config.hjson`: +## Menus +The core of a ENiGMA½ based BBS is `menu.hjson`. Note that when `menu.hjson` is referenced, we're actually talking about `config/yourboardname-menu.hjson` or similar. This file determines the menus (or screens) a user can see, the order they come in and how they interact with each other, ACS configuration, etc. Like all configuration within ENiGMA½, menu configuration is done in [HJSON](https://hjson.org/) format. See [HJSON General Information](hjson.md) for more information. -````hjson -general: { - menuFile: yourboardname.hjson -} -```` -This document and others will refer to `menu.hjson`. This should be seen as an alias to `yourboardname.hjson` - -## The Basics -Like all configuration within ENiGMA½, menu configuration is done in [HJSON](https://hjson.org/) format. - -Entries in `menu.hjson` are objects or _sections_ defining a menu. A menu in this sense is something the user can see or visit. Examples include but are not limited to: +Entries in `menu.hjson` are often referred to as *blocks* or *sections*. Each entry defines a menu. A menu in this sense is something the user can see or visit. Examples include but are not limited to: * Classical Main, Messages, and File menus * Art file display @@ -23,21 +14,47 @@ Entries in `menu.hjson` are objects or _sections_ defining a menu. A menu in thi Menu entries live under the `menus` section of `menu.hjson`. The *key* for a menu is it's name that can be referenced by other menus and areas of the system. ## Common Menu Entry Members -* `desc`: A friendly description that can be found in places such as "Who's Online" or the `%MD` MCI code. -* `art`: An art file specification. -* `next`: Specifies the next menu to go to next. Can be explicit or an array of possibilites dependent on ACS. See **Flow Control** in the **ACS Checks** section below. -* `prompt`: Specifies a prompt, by name, to use along with this menu. -* `form`: Defines one or more forms available on this menu. -* `submit`: Defines a submit handler when using `prompt`. -* `config`: May contain any of the following standard configuration members in addition to per-module defined types (see appropriate module for more information): - * `cls`: If `true` the screen will be cleared before showing this menu. - * `pause`: If `true` a pause will occur after showing this menu. Useful for simple menus such as displaying art or status screens. - * `nextTimeout`: Sets the number of **milliseconds** before the system will automatically advanced to the `next` menu. - * `baudRate`: Sets the SyncTERM style emulated baud rate. May be `300`, `600`, `1200`, `2400`, `4800`, `9600`, `19200`, `38400`, `57600`, `76800`, or `115200`. A value of `ulimited`, `off`, or `0` resets (disables) the rate. See [this specification](https://github.com/protomouse/synchronet/blob/master/src/conio/cterm.txt) for more information. - * `font`: Sets the SyncTERM style font. May be one of the following: `cp437`, `cp1251`, `koi8_r`, `iso8859_2`, `iso8859_4`, `cp866`, `iso8859_9`, `haik8`, `iso8859_8`, `koi8_u`, `iso8859_15`, `iso8859_4`, `koi8_r_b`, `iso8859_4`, `iso8859_5`, `ARMSCII_8`, `iso8859_15`, `cp850`, `cp850`, `cp885`, `cp1251`, `iso8859_7`, `koi8-r_c`, `iso8859_4`, `iso8859_1`, `cp866`, `cp437`, `cp866`, `cp885`, `cp866_u`, `iso8859_1`, `cp1131`, `c64_upper`, `c64_lower`, `c128_upper`, `c128_lower`, `atari`, `pot_noodle`, `mo_soul`, `microknight_plus`, `topaz_plus`, `microknight`, `topaz`. See [this specification](https://github.com/protomouse/synchronet/blob/master/src/conio/cterm.txt) for more information. +Below is a table of **common** menu entry members. These members apply to most entries, though entries that are backed by a specialized module (ie: `module: bbs_list`) may differ. See documentation for the module in question for particulars. + +| Item | Description | +|--------|--------------| +| `desc` | A friendly description that can be found in places such as "Who's Online" or wherever the `%MD` MCI code is used. | +| `art` | An art file *spec*. See [General Art Information](/docs/art/general.md). | +| `next` | Specifies the next menu entry to go to next. Can be explicit or an array of possibilities dependent on ACS. See **Flow Control** in the **ACS Checks** section below. If `next` is not supplied, the next menu is this menus parent. | +| `prompt` | Specifies a prompt, by name, to use along with this menu. Prompts are configured in `prompt.hjson`. | +| `submit` | Defines a submit handler when using `prompt`. +| `form` | An object defining one or more *forms* available on this menu. | +| `module` | Sets the module name to use for this menu. | +| `config` | An object containing additional configuration. See **Config Block** below. | + +### Config Block +The `config` block for a menu entry can contain common members as well as a per-module (when `module` is used) settings. + +| Item | Description | +|------|-------------| +| `cls` | If `true` the screen will be cleared before showing this menu. | +| `pause` | If `true` a pause will occur after showing this menu. Useful for simple menus such as displaying art or status screens. | +| `nextTimeout` | Sets the number of **milliseconds** before the system will automatically advanced to the `next` menu. | +| `baudRate` | See baud rate information in [General Art Information](/docs/art/general.md). | +| `font` | Sets a SyncTERM style font to use when displaying this menus `art`. See font listing in [General Art Information](/docs/art/general.md). | +| `menuFlags` | An array of menu flag(s) controlling menu behavior. See **Menu Flags** below. + +#### Menu Flags +The `menuFlags` field of a `config` block can change default behavior of a particular menu. + +| Flag | Description | +|------|-------------| +| `noHistory` | Prevents the menu from remaining in the menu stack / history. When this flag is set, when the **next** menu falls back, this menu will be skipped and the previous menu again displayed instead. Example: menuA -> menuB(noHistory) -> menuC: Exiting menuC returns the user to menuA. | +| `popParent` | When *this* menu is exited, fall back beyond the parent as well. Often used in combination with `noHistory`. | +| `forwardArgs` | If set, when the next menu is entered, forward any `extraArgs` arguments to *this* menu on to it. | + ## Forms -TODO +ENiGMA½ uses a concept of *forms* in menus. A form is a collection of associated *views*. Consider a New User Application using the `nua` module: The default implementation utilizes a single form with multiple EditTextView views, a submit button, etc. Forms are identified by number starting with `0`. A given menu may have mutiple forms (often associated with different states or screens within the menu). + +Menus may also support more than one layout type by using a *MCI key*. A MCI key is a alpha-numerically sorted key made from 1:n MCI codes. This lets the system choose the appropriate set of form(s) based on theme or random art. An example of this may be a matrix menu: Perhaps one style of your matrix uses a vertical light bar (`VM` key) while another uses a horizontal (`HM` key). The system can discover the correct form to use by matching MCI codes found in the art to that of the available forms defined in `menu.hjson`. + +For more information on views and associated MCI codes, see [MCI Codes](/docs/art/mci.md). ## Submit Handlers TODO @@ -49,67 +66,69 @@ Let's look a couple basic menu entries: telnetConnected: { art: CONNECT next: matrix - options: { nextTimeout: 1500 } + config: { nextTimeout: 1500 } } ``` -The above entry `telnetConnected` is set as the Telnet server's first menu entry (set by `firstMenu` in the Telnet server's config). - -An art pattern of `CONNECT` is set telling the system to look for `CONNECT.*` where `` represents a optional integer in art files to cause randomness, e.g. `CONNECT1.ANS`, `CONNECT2.ANS`, and so on. If desired, you can also be explicit by supplying a full filename with an extention such as `CONNECT.ANS`. - -The entry `next` sets up the next menu, by name, in the stack (`matrix`) that we'll go to after `telnetConnected`. - -Finally, an `options` object may contain various common options for menus. In this case, `nextTimeout` tells the system to proceed to the `next` entry automatically after 1500ms. +The above entry `telnetConnected` is set as the Telnet server's first menu entry (set by `firstMenu` in the Telnet server's config). The entry sets up a few things: +* A `art` spec of `CONNECT`. (See [General Art Information](/docs/art/general.md)). +* A `next` entry up the next menu, by name, in the stack (`matrix`) that we'll go to after `telnetConnected`. +* An `config` block containing a single `nextTimeout` field telling the system to proceed to the `next` (`matrix`) entry automatically after 1500ms. Now let's look at `matrix`, the `next` entry from `telnetConnected`: ```hjson matrix: { - art: matrix + art: MATRIX desc: Login Matrix form: { - 0: { - VM: { - mci: { - VM1: { - submit: true - focus: true - items: [ "login", "apply", "log off" ] - argName: matrixSubmit + 0: { + // + // Here we have a MCI key of "VM". In this case we could + // omit this level since no other keys are present. + // + VM: { + mci: { + VM1: { + submit: true + focus: true + items: [ "login", "apply", "log off" ] + argName: matrixSubmit + } + } + submit: { + *: [ + { + value: { matrixSubmit: 0 } + action: @menu:login + } + { + value: { matrixSubmit: 1 }, + action: @menu:newUserApplication + } + { + value: { matrixSubmit: 2 }, + action: @menu:logoff + } + ] + } } + + // + // If we wanted, we could declare a "HM" MCI key block here. + // This would allow a horizontal matrix style when the matrix art + // loaded contained a %HM code. + // } - submit: { - *: [ - { - value: { matrixSubmit: 0 } - action: @menu:login - } - { - value: { matrixSubmit: 1 }, - action: @menu:newUserApplication - } - { - value: { matrixSubmit: 2 }, - action: @menu:logoff - } - ] - } - } - } } } ``` -In the above entry, you'll notice `form`. This defines a form(s) object. In this case, a single form -by ID of `0`. The system is then told to use a block only when the resulting art provides a `VM` -(*VerticalMenuView*) MCI entry. `VM1` is then setup to `submit` and start focused via `focus: true` -as well as have some menu entries ("login", "apply", ...) defined. We provide an `argName` for this -action as `matrixSubmit`. +In the above entry, you'll notice `form`. This defines a form(s) object. In this case, a single form by ID of `0`. The system is then told to use a block only when the resulting art provides a `VM` (*VerticalMenuView*) MCI entry. Some other bits about the form: -The `submit` object tells the system to attempt to apply provided match entries from any view ID (`*`). - Upon submit, the first match will be executed. For example, if the user selects "login", the first entry - with a value of `{ matrixSubmit: 0 }` will match causing `action` of `@menu:login` to be executed (go - to `login` menu). +* `VM1` is then setup to `submit` and start focused via `focus: true` as well as have some menu entries ("login", "apply", ...) defined. We provide an `argName` of `matrixSubmit` for this element view. +* The `submit` object tells the system to attempt to apply provided match entries from any view ID (`*`). +* Upon submit, the first match will be executed. For example, if the user selects "login", the first entry with a value of `{ matrixSubmit: 0 }` will match (due to 0 being the first index in the list and `matrixSubmit` being the arg name in question) causing `action` of `@menu:login` to be executed (go to `login` menu). ## ACS Checks Menu modules can check user ACS in order to restrict areas and perform flow control. See [ACS](acs.md) for available ACS syntax. @@ -141,4 +160,23 @@ login: { } ] } -``` \ No newline at end of file +``` + +### Art Asset Selection +Another area in which you can apply ACS in a menu is art asset specs. + +```hjson +someMenu: { + desc: Neato Dorito + art: [ + { + acs: GM[couriers] + art: COURIERINFO + } + { + // show ie: EVERYONEELSE.ANS to everyone else + art: EVERYONEELSE + } + ] +} +``` diff --git a/docs/configuration/prompt-hjson.md b/docs/configuration/prompt-hjson.md index 7b7a3ab5..993e5b8e 100644 --- a/docs/configuration/prompt-hjson.md +++ b/docs/configuration/prompt-hjson.md @@ -3,4 +3,6 @@ layout: page title: prompt.hjson --- :zap: This page is to describe general information the `prompt.hjson` file. It -needs fleshing out, please submit a PR if you'd like to help! \ No newline at end of file +needs fleshing out, please submit a PR if you'd like to help! + +See [HJSON General Information](hjson.md) for more information. diff --git a/docs/configuration/sysop-setup.md b/docs/configuration/sysop-setup.md index b8c4beb6..502f412e 100644 --- a/docs/configuration/sysop-setup.md +++ b/docs/configuration/sysop-setup.md @@ -2,5 +2,4 @@ layout: page title: SysOp Setup --- -SySop privileges will be granted to the first user to log into a fresh ENiGMA½ installation. - +SySop privileges will be granted to the first user to log into a fresh ENiGMA½ installation. +ops belong to the `sysop` user group by default. \ No newline at end of file diff --git a/docs/filebase/acs.md b/docs/filebase/acs.md index 50527928..63884c71 100644 --- a/docs/filebase/acs.md +++ b/docs/filebase/acs.md @@ -2,8 +2,8 @@ layout: page title: ACS --- - -If no `acs` block is supplied in a file area definition, the following defaults apply to an area: +## File Base ACS +[ACS Codes](/docs/configuration/acs.md) may be used to control acess to File Base areas by specifying an `acs` string in a file area's definition. If no `acs` is supplied in a file area definition, the following defaults apply to an area: * `read` (list, download, etc.): `GM[users]` * `write` (upload): `GM[sysops]` diff --git a/docs/installation/manual.md b/docs/installation/manual.md index 01c5ca47..a24cd3eb 100644 --- a/docs/installation/manual.md +++ b/docs/installation/manual.md @@ -19,16 +19,21 @@ are OK) for Windows users. Note that you **should only need the Visual C++ compo * [git](https://git-scm.com/downloads) to check out the ENiGMA source code. ## Node.js -If you're new to Node.js and/or do not care about Node itself and just want to get ENiGMA½ running -these steps should get you going on most \*nix type environments: +### With NVM +Node Version Manager (NVM) is an excellent way to install and manage Node.js versions on most UNIX-like environments. [Get the latest version here](https://github.com/creationix/nvm). The install should look something like this: ```bash -curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.33.0/install.sh | bash -nvm install 6 -nvm use 6 +curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.33.11/install.sh | bash ``` -If the above completed without errors, you should now have `nvm`, `node`, and `npm` installed and in your environment. +Next, install Node.js with NVM: +```bash +nvm install 10 +nvm use 10 +nvm alias default 10 +``` + +If the above steps completed without errors, you should now have `nvm`, `node`, and `npm` installed and in your environment. For Windows nvm-like systems exist ([nvm-windows](https://github.com/coreybutler/nvm-windows), ...) or [just download the installer](https://nodejs.org/en/download/). @@ -41,31 +46,25 @@ git clone https://github.com/NuSkooler/enigma-bbs.git ## Install Node Packages ```bash cd enigma-bbs -npm install +npm install # yarn also works ``` ## Other Recommended Packages +ENiGMA BBS makes use of a few packages for archive and legacy protocol support. They're not pre-requisites for running ENiGMA, but without them you'll miss certain functionality. Once installed, they should be made available on your system path. -ENiGMA BBS makes use of a few packages for unarchiving and modem support. They're not pre-requisites for -running ENiGMA, but without them you'll miss certain functionality. Once installed, they should be made -available on your system path. - -| Package | Description | Debian/Ubuntu Package (APT/DEP) | Red Hat Package (YUM/RPM) | Windows Package | +| Package | Description | Debian/Ubuntu Package (APT/DEP) | Red Hat Package (YUM/RPM) | Windows Package | |------------|-----------------------------------|--------------------------------------------|---------------------------------------------------|------------------------------------------------------------------| | arj | Unpacking arj archives | `arj` | n/a, binaries [here](http://arj.sourceforge.net/) | [ARJ](http://arj.sourceforge.net/) | | 7zip | Unpacking zip, rar, archives | `p7zip-full` | `p7zip-full` | [7-zip](http://www.7-zip.org/) | | lha | Unpacking lha archives | `lhasa` | n/a, source [here](http://www2m.biglobe.ne.jp/~dolphin/lha/lha.htm) | Unknown | | Rar | Unpacking rar archives | `unrar` | n/a, binaries [here](https://www.rarlab.com/download.htm) | Unknown | -| lrzsz | sz/rz: X/Y/Z modem support | `lrzsz` | `lrzsz` | Unknown | -| sexyz | SexyZ modem support | [sexyz](https://l33t.codes/outgoing/sexyz) | [sexyz](https://l33t.codes/outgoing/sexyz) | Available with [Synchronet](http://wiki.synchro.net/install:win) | +| lrzsz | sz/rz: X/Y/Z protocol support | `lrzsz` | `lrzsz` | Unknown | +| sexyz | SexyZ protocol support | [sexyz](https://l33t.codes/outgoing/sexyz) | [sexyz](https://l33t.codes/outgoing/sexyz) | Available with [Synchronet](http://wiki.synchro.net/install:win) | | exiftool | [ExifTool](https://www.sno.phy.queensu.ca/~phil/exiftool/) | libimage-exiftool-perl | perl-Image-ExifTool | Unknown | xdms | Unpack/view Amiga DMS | [xdms](http://manpages.ubuntu.com/manpages/trusty/man1/xdms.1.html) | xdms | Unknown ## Config Files - -You'll need a basic configuration to get started. The main system configuration is handled via -`config/config.hjson`. This is an [HJSON](http://hjson.org/) file (compiliant JSON is also OK). -See [Configuration](../configuration/) for more information. +You'll need a basic configuration to get started. The main system configuration is handled via `config/config.hjson`. This is an [HJSON](http://hjson.org/) file (compiliant JSON is also OK). See [Configuration](../configuration/) for more information. Use `oputil.js` to generate your **initial** configuration: diff --git a/docs/installation/os-hardware.md b/docs/installation/os-hardware.md index a49283b4..6332ee82 100644 --- a/docs/installation/os-hardware.md +++ b/docs/installation/os-hardware.md @@ -2,10 +2,13 @@ layout: page title: OS & Hardware Specific Information --- -There are multiple ways of installing ENiGMA BBS, depending on your level of experience and desire to do -things manually versus have it automated for you. +There are multiple ways of installing ENiGMA BBS, depending on your level of experience and desire to do things manually versus have it automated for you. -| Method | Notes | -|----------------------------------------|---------------------------------------------------------------------------------------------| -| [Raspberry Pi](rpi) | All Raspberry Pi models work great with ENiGMA½! | -| [Windows](windows) | Compatible with all Windows Operating Systems | +In general, please see [Installation Methods](installation-methods.md) and [Install Script](install-script.md). + +Below are some special cases: + +| Method | Notes | +|--------|-------| +| [Raspberry Pi](rpi.md) | All Raspberry Pi models work great with ENiGMA½! | +| [Windows](windows.md) | Compatible with all Windows Operating Systems | diff --git a/docs/installation/windows.md b/docs/installation/windows.md index 4eaed906..a68afe97 100644 --- a/docs/installation/windows.md +++ b/docs/installation/windows.md @@ -36,11 +36,13 @@ ENiGMA½ will run on both 32bit and 64bit Windows. If you want to run 16bit door *Add 7zip to your path so `7z` can be called from the console 1. Right click `This PC` and Select `Properties` - 2. Go to the `Advanced` Tab and click on `Enviromental Varibles` - 3. Select `Path` under `System Varibles` and click `Edit` + 2. Go to the `Advanced` Tab and click on `Environment Variables` + 3. Select `Path` under `System Variables` and click `Edit` 4. Click `New` and paste the path to 7zip 5. Close your console window and reopen. You can type `7z` to make sure it's working. +(Please see [Archivers](/docs/archivers.md) for additional archive utilities!) + 3. Install [Git](https://git-scm.com/downloads) and optionally [TortoiseGit](https://tortoisegit.org/download/). 4. Clone ENiGMA½ - browse to the directory you want and run diff --git a/docs/messageareas/bso-import-export.md b/docs/messageareas/bso-import-export.md index 106fd65a..aab72429 100644 --- a/docs/messageareas/bso-import-export.md +++ b/docs/messageareas/bso-import-export.md @@ -4,7 +4,7 @@ title: BSO Import / Export --- The scanner/tosser module `ftn_bso` provides **B**inkley **S**tyle **O**utbound (BSO) import/toss and scan/export of messages EchoMail and NetMail messages. Configuration is supplied in `config.hjson` under `scannerTossers.ftn_bso`. -:information_source: ENiGMA½'s `ftn_bso` module is not a mailer and **makes no attempts** to perfrom packet transport! An external utility such as Binkd is required for this! +:information_source: ENiGMA½'s `ftn_bso` module is not a mailer and **makes no attempts** to perfrom packet transport! An external [mailer](http://www.filegate.net/bbsmailers.htm) such as [Binkd](https://github.com/pgul/binkd) is required for this! Let's look at some of the basic configuration: @@ -23,7 +23,7 @@ Schedules can be defined for importing and exporting via `import` and `export` u * `@immediate`: A message will be immediately exported if this trigger is defined in a schedule. Only used for `export`. * `@watch:/path/to/file`: This trigger watches the path specified for changes and will trigger an import or export when such events occur. Only used for `import`. - * Free form text can be things like `at 5:00 pm` or `every 2 hours`. + * Free form [Later style](https://bunkat.github.io/later/parsers.html#text) text — can be things like `at 5:00 pm` or `every 2 hours`. See [Later text parsing documentation](http://bunkat.github.io/later/parsers.html#text) for more information. @@ -45,14 +45,14 @@ See [Later text parsing documentation](http://bunkat.github.io/later/parsers.htm ## Nodes The `nodes` section defines how to export messages for one or more uplinks. -A node entry starts with a FTN style address (up to 5D) **as a key** in `config.hjson`. This key may contain wildcard(s) for net/zone/node/point/domain. +A node entry starts with a [FTN address](http://ftsc.org/docs/old/fsp-1028.001) (up to 5D) **as a key** in `config.hjson`. This key may contain wildcard(s) for net/zone/node/point/domain. | Config Item | Required | Description | |------------------|----------|---------------------------------------------------------------------------------| -| `packetType` | :-1: | `2`, `2.2`, or `2+`. Defaults to `2+` for modern mailer compatiability | -| `packetPassword` | :-1: | Password for the packet | -| `encoding` | :-1: | Encoding to use for message bodies; Defaults to `utf-8` | -| `archiveType` | :-1: | Specifies the archive type for ArcMail bundles. Must be a valid archiver name such as `zip` (See archiver configuration) | +| `packetType` | :-1: | `2`, `2.2`, or `2+`. Defaults to `2+` for modern mailer compatiability. | +| `packetPassword` | :-1: | Optional password for the packet | +| `encoding` | :-1: | Encoding to use for message bodies; Defaults to `utf-8`. | +| `archiveType` | :-1: | Specifies the archive type (by extension) for ArcMail bundles. This should be `zip` for most setups. Other valid examples include `arc`, `arj`, `lhz`, `pak`, `sqz`, or `zoo`. See docs on archiver configuration for more information. | **Example**: ```hjson @@ -60,9 +60,9 @@ A node entry starts with a FTN style address (up to 5D) **as a key** in `config. scannerTossers: { ftn_bso: { nodes: { - "21:*": { + "21:*": { // wildcard address packetType: 2+ - packetPassword: mypass + packetPassword: D@TP4SS encoding: cp437 archiveType: zip } @@ -118,4 +118,36 @@ scannerTossers: { } } } -``` \ No newline at end of file +``` + +## Binkd +Since Binkd is a very common mailer, a few tips on integrating it with ENiGMA½: + +### Scheduling Polls +Binkd does not have it's own scheduler. Instead, you'll need to set up an Event Scheduler entry or perhaps a cron job: + +First, create a script that runs through all of your uplinks. For example: +```bash +#!/bin/bash +UPLINKS=("21:1/100@fsxnet" "80:774/1@retronet" "10:101/0@araknet") +for uplink in "${UPLINKS[@]}" +do + /usr/local/sbin/binkd -p -P $uplink /home/enigma/xibalba/misc/binkd_xibalba.conf +done +``` + +Now, create an Event Scheuler entry in your `config.hjson`. As an example: +```hjson +eventScheduler: { + events: { + pollWithBink: { + // execute the script above very 1 hours + schedule: every 1 hours + action: @execute:/path/to/poll_bink.sh + } + } +} +``` + +## Additional Resources +* [Blog entry on setting up ENiGMA + Binkd on CentOS7](https://l33t.codes/enigma-12-binkd-on-centos-7/). Note that this references an **older version**, so be wary of the `config.hjson` refernces! diff --git a/docs/modding/file-base-download-manager.md b/docs/modding/file-base-download-manager.md new file mode 100644 index 00000000..023ae478 --- /dev/null +++ b/docs/modding/file-base-download-manager.md @@ -0,0 +1,23 @@ +--- +layout: page +title: File Base Download Manager +--- +## File Base Download Manager Module +The `file_base_download_manager` module provides a download queue manager for "legacy" (X/Y/Z-Modem, etc.) downloads. Web (HTTP/HTTPS) download functionality can be optionally available when the web content server is enabled. + +## Configuration +### Configuration Block +Available `config` block entries: +* `webDlExpireTimeFormat`: Sets the moment.js style format for web download expiration date/time. +* `fileTransferProtocolSelection`: Overrides the default `fileTransferProtocolSelection` target for a protocol selection menu. +* `emptyQueueMenu`: Overrides the default `fileBaseDownloadManagerEmptyQueue` target for menu to show when the users D/L queue is empty. + +### Theming +The following `itemFormat` object is provided to MCI 1 (ie: `%VM1`) and MCI 10+ custom fields: +* `fileId`: File ID. +* `areaTag`: Area tag. +* `fileName`: Entry filename. +* `path`: Full file path. +* `byteSize`: Size in bytes of file. +* `webDlLink`: Web download link including [VTX style ANSI ESC sequences](https://raw.githubusercontent.com/codewar65/VTX_ClientServer/master/vtx.txt). +* `webDlExpire`: Expiration date/time for this link. Formatted using `webDlExpireTimeFormat`. \ No newline at end of file diff --git a/docs/modding/file-base-web-download-manager.md b/docs/modding/file-base-web-download-manager.md new file mode 100644 index 00000000..1dadca00 --- /dev/null +++ b/docs/modding/file-base-web-download-manager.md @@ -0,0 +1,26 @@ +--- +layout: page +title: File Base Web Download Manager +--- +## File Base Web Download Manager Module +The `file_base_web_download_manager` module provides a download queue manager for web (HTTP/HTTPS) based downloads. This module relies on having the web server enabled at a minimum. + +Web downloads can be a convienent way for users to download larger (100+ MiB) files where legacy protocols often have trouble. Additionally, batch downloads can be streamed to users in a single zip archive. + +## Configuration +### Configuration Block +Available `config` block entries: +* `webDlExpireTimeFormat`: Sets the moment.js style format for web download expiration date/time. +* `emptyQueueMenu`: Overrides the default `fileBaseDownloadManagerEmptyQueue` target for menu to show when the users D/L queue is empty. + +### Theming +The following `itemFormat` object is provided to MCI 1 (ie: `%VM1`) and custom range MCI 10+ custom fields: +* `fileId`: File ID. +* `areaTag`: Area tag. +* `fileName`: Entry filename. +* `path`: Full file path. +* `byteSize`: Size in bytes of file. +* `webDlLinkRaw`: Web download link. +* `webDlLink`: Web download link including [VTX style ANSI ESC sequences](https://raw.githubusercontent.com/codewar65/VTX_ClientServer/master/vtx.txt). +* `webDlExpire`: Expiration date/time for this link. Formatted using `webDlExpireTimeFormat`. + diff --git a/docs/modding/msg-area-list.md b/docs/modding/msg-area-list.md index e3f5cde1..499b7fa6 100644 --- a/docs/modding/msg-area-list.md +++ b/docs/modding/msg-area-list.md @@ -14,4 +14,4 @@ The following `itemFormat` object is provided to MCI 1 (ie: `%VM1`): The following additional MCIs are updated as the user changes selections in the main list: * MCI 2 (ie: `%TL2` or `%M%2`) is updated with the area description. -* MCI 10+ (ie `%TL10`...) are custom ranges updated with the same information available above in `itemFormat`. +* MCI 10+ (ie `%TL10`...) are custom ranges updated with the same information available above in `itemFormat`. Use `areaListItemFormat##`. diff --git a/docs/modding/set-newscan-date.md b/docs/modding/set-newscan-date.md new file mode 100644 index 00000000..5649edac --- /dev/null +++ b/docs/modding/set-newscan-date.md @@ -0,0 +1,29 @@ +--- +layout: page +title: Set Newscan Date Module +--- +## Set Newscan Date Module +The `set_newscan_date` module allows setting newscan dates (aka pointers) for message conferences and areas as well as within the file base. Users can select specific conferences/areas or all (where applicable). + +## Configuration +### Configuration Block +Available `config` block entries are as follows: +* `target`: Choose from `message` for message conferences & areas, or `file` for file base areas. +* `scanDateFormat`: Format for scan date. This format must align with the **output** of the MaskEditView (`%ME1`) MCI utilized for input. Defaults to `YYYYMMDD` (which matches mask of `####/##/##`). + +### Theming +#### Message Conference & Areas +When `target` is `message`, the following `itemFormat` object is provided to MCI 2 (ie: `%SM2`): +* `conf`: An object containing: + * `confTag`: Conference tag. + * `name`: Conference name. Also available in `{text}`. + * `desc`: Conference description. +* `area`: An object containing: + * `areaTag`: Area tag. + * `name`: Area name. Also available in `{text}`. + * `desc`: Area description. + +When dealing with the file base, ENiGMA½ does not currently have the ability to set newscan dates for specific areas. No `%SM2` is used in this case. + +### Submit Actions +Submit action should map to `@method:scanDateSubmit` and provide `scanDate` in form data. For message conf/areas (`target` of `message`), `targetSelection` should be also be provided in form data: An index to the selected conf/area. diff --git a/docs/modding/show-art.md b/docs/modding/show-art.md new file mode 100644 index 00000000..c00d7009 --- /dev/null +++ b/docs/modding/show-art.md @@ -0,0 +1,70 @@ +--- +layout: page +title: User List +--- +## The Show Art Module +The built in `show_art` module add some advanced ways in which you can configure your system to display art assets beyond what a standard menu entry can provide. For example, based on user selection of a file or message base area. + +## Configuration +### Config Block +Available `config` block entries: +* `method`: Set the method in which to show art. See **Methods** below. +* `optional`: Is this art required or optional? If non-optional and we cannot show art based on `method`, it is an error. +* `key`: Used for some `method`s. See **Methods** + +### Methods +#### Extra Args +When `method` is `extraArgs`, the module selects an *art spec* from a value found within `extraArgs` that were passed to `show_art` by `key`. Consider the following: + +Given an `menu.hjson` entry: +```hjson +showWithExtraArgs: { + module: show_art + config: { + method: extraArgs + key: fooBaz + } +} +``` +If the `showWithExtraArgs` menu was entered and passed `extraArgs` as the following: +```json +{ + fizzBang : true, + fooBaz : "LOLART" +} +``` + +...then the system would use the *art spec* of `LOLART`. + +#### Area & Conferences +Handy for inserting into File Base, Message Conferences, or Mesage Area selections selections. When `method` is `fileBaseArea`, `messageConf`, or `messageArea` the selected conf/area's associated *art spec* is utilized. Example: + +Given a file base entry in `config.hjson`: +```hjson +areas: { + all_ur_base: { + name: All Your Base + desc: chown -r us ./base + art: ALLBASE + } +} +``` + +A menu entry may look like this: +```hjson +showFileBaseAreaArt: { + module: show_art + config: { + method: fileBaseArea + cls: true + pause: true + menuFlags: [ "popParent", "noHistory" ] + } +} +``` + +...if the user choose the "All Your Base" area, the *art spec* of `ALLBASE` would be selected and displayed. + +The only difference for `messageConf` or `messageArea` methods are where the art is defined (which is always next to the conf or area declaration in `config.hjson`). + +While `key` can be overridden, the system uses `areaTag` for message/file area selections, and `confTag` for conference selections by default. diff --git a/docs/oputil/index.md b/docs/oputil/index.md deleted file mode 100644 index b23a7362..00000000 --- a/docs/oputil/index.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -layout: page -title: oputil ---- - -oputil is the ENiGMA½ command line utility for maintaining users, file areas and message areas, as well as -generating your initial ENiGMA½ config. - -## File areas -The `oputil.js` +op utilty `fb` command has tools for managing file bases. For example, to import existing -files found within **all** storage locations tied to an area and set tags `tag1` and `tag2` to each import: - -```bash -oputil.js fb scan some_area --tags tag1,tag2 -``` - -See `oputil.js fb --help` for additional information. \ No newline at end of file diff --git a/docs/troubleshooting/monitoring-logs.md b/docs/troubleshooting/monitoring-logs.md index 7c04cb40..dd665f5c 100644 --- a/docs/troubleshooting/monitoring-logs.md +++ b/docs/troubleshooting/monitoring-logs.md @@ -2,14 +2,21 @@ layout: page title: Monitoring Logs --- -ENiGMA½ does not produce much to stdout. Logs are produced by Bunyan which outputs each entry as a -JSON object. +ENiGMA½ does not produce much to stdout. Logs are produced by Bunyan which outputs each entry as a JSON object. Start by installing bunyan and making it available on your path: - npm install bunyan -g +```bash +npm install bunyan -g +``` + +or with Yarn: +```bash +yarn global add bunyan +``` To tail logs in a colorized and pretty format, issue the following command: - - tail -F /path/to/enigma-bbs/logs/enigma-bbs.log | bunyan +```bash +tail -F /path/to/enigma-bbs/logs/enigma-bbs.log | bunyan +``` diff --git a/misc/acs_parser.pegjs b/misc/acs_parser.pegjs index 4c9bc37b..ed6089ba 100644 --- a/misc/acs_parser.pegjs +++ b/misc/acs_parser.pegjs @@ -3,6 +3,8 @@ const client = options.client; const user = options.client.user; + const UserProps = require('./user_property.js'); + const moment = require('moment'); function checkAccess(acsCode, value) { @@ -19,7 +21,7 @@ value = [ value ]; } - const userAccountStatus = parseInt(user.properties.account_status, 10); + const userAccountStatus = user.getPropertyAsNumber(UserProps.AccountStatus); return value.map(n => parseInt(n, 10)).includes(userAccountStatus); }, EC : function isEncoding() { @@ -44,15 +46,15 @@ return value.map(n => parseInt(n, 10)).includes(client.node); }, NP : function numberOfPosts() { - const postCount = parseInt(user.properties.post_count, 10) || 0; + const postCount = user.getPropertyAsNumber(UserProps.PostCount) || 0; return !isNaN(value) && postCount >= value; }, NC : function numberOfCalls() { - const loginCount = parseInt(user.properties.login_count, 10); + const loginCount = user.getPropertyAsNumber(UserProps.LoginCount); return !isNaN(value) && loginCount >= value; }, AA : function accountAge() { - const accountCreated = moment(user.properties.account_created); + const accountCreated = moment(user.getProperty(UserProps.AccountCreated)); const now = moment(); const daysOld = accountCreated.diff(moment(), 'days'); return !isNaN(value) && @@ -61,36 +63,36 @@ daysOld >= value; }, BU : function bytesUploaded() { - const bytesUp = parseInt(user.properties.ul_total_bytes, 10) || 0; + const bytesUp = user.getPropertyAsNumber(UserProps.FileUlTotalBytes) || 0; return !isNaN(value) && bytesUp >= value; }, UP : function uploads() { - const uls = parseInt(user.properties.ul_total_count, 10) || 0; + const uls = user.getPropertyAsNumber(UserProps.FileUlTotalCount) || 0; return !isNaN(value) && uls >= value; }, BD : function bytesDownloaded() { - const bytesDown = parseInt(user.properties.dl_total_bytes, 10) || 0; + const bytesDown = user.getPropertyAsNumber(UserProps.FileDlTotalBytes) || 0; return !isNaN(value) && bytesDown >= value; }, DL : function downloads() { - const dls = parseInt(user.properties.dl_total_count, 10) || 0; + const dls = user.getPropertyAsNumber(UserProps.FileDlTotalCount) || 0; return !isNaN(value) && dls >= value; }, NR : function uploadDownloadRatioGreaterThan() { - const ulCount = parseInt(user.properties.ul_total_count, 10) || 0; - const dlCount = parseInt(user.properties.dl_total_count, 10) || 0; + const ulCount = user.getPropertyAsNumber(UserProps.FileUlTotalCount) || 0; + const dlCount = user.getPropertyAsNumber(UserProps.FileDlTotalCount) || 0; const ratio = ~~((ulCount / dlCount) * 100); return !isNaN(value) && ratio >= value; }, KR : function uploadDownloadByteRatioGreaterThan() { - const ulBytes = parseInt(user.properties.ul_total_bytes, 10) || 0; - const dlBytes = parseInt(user.properties.dl_total_bytes, 10) || 0; + const ulBytes = user.getPropertyAsNumber(UserProps.FileUlTotalBytes) || 0; + const dlBytes = user.getPropertyAsNumber(UserProps.FileDlTotalBytes) || 0; const ratio = ~~((ulBytes / dlBytes) * 100); return !isNaN(value) && ratio >= value; }, PC : function postCallRatio() { - const postCount = parseInt(user.properties.post_count, 10) || 0; - const loginCount = parseInt(user.properties.login_count, 10); + const postCount = user.getPropertyAsNumber(UserProps.PostCount) || 0; + const loginCount = user.getPropertyAsNumber(UserProps.LoginCount) || 0; const ratio = ~~((postCount / loginCount) * 100); return !isNaN(value) && ratio >= value; }, diff --git a/misc/config_template.in.hjson b/misc/config_template.in.hjson index 3acb0823..489a7cb3 100644 --- a/misc/config_template.in.hjson +++ b/misc/config_template.in.hjson @@ -66,7 +66,8 @@ // See https://github.com/trentm/node-bunyan#streams // // Remember you can pipe logs through Bunyan to pretty-print: - // tail -F ./logs/enigma-bbs.log | bunyan + // Linux : tail -F ./logs/enigma-bbs.log | bunyan + // PowerShell : Get-Content .\enigma-bbs.log -Tail 15 | bunyan.cmd // // (npm install -g bunyan to get the binary) // @@ -210,6 +211,9 @@ port: XXXXX enabled: false + // bannerFile path in misc/ by default. Full paths allowed. + bannerFile: XXXXX + // // The Gopher Content Server can export message base // conferences and areas via the "messageConferences" key. @@ -330,7 +334,7 @@ // ] // // - // Set default group(s) new users should automatically be assigned to + // Set default group(s) new users should automatically be assigned to: // defaultGroups : [ // "lamerz" // ] @@ -348,6 +352,23 @@ // Usernames reserved for applying to your system newUserNames: [] + // Handling of failed logins + failedLogin : { + // disconnect after N failed attempts. 0=disabled. + disconnect : XXXXX + + // Lock the user out after N failed attempts. 0=disabled. + lockAccount : XXXXX + + // + // If locked out, how long until the user can login again? + // Set to 0 to disable auto-unlock + // + autoUnlockMinutes : XXXXX + }, + + // Allow email driven password resets to unlock accounts? + unlockAtEmailPwReset : XXXXX } // Archive files and related @@ -378,10 +399,29 @@ // } + // + // Use the Event Scheduler to set up arbitrary scheduled events + // using Later style syntax and/or @watch files. + // See docs/event-scheduler.md for more information. + // + eventScheduler: { + events: { + // Example: + // + // sampleEvent: { + // schedule: every 2 hours + // action: @execute:/path/to/some/script.sh + // args: [ + // "--foo", "--bar" + // ] + // } + } + } + statLog: { systemEvents: { // Max login history event records kept. -1 = unlimited loginHistoryMax: -1 } } -} \ No newline at end of file +} diff --git a/misc/gopher_banner.asc b/misc/gopher_banner.asc new file mode 100644 index 00000000..b758e066 --- /dev/null +++ b/misc/gopher_banner.asc @@ -0,0 +1,9 @@ +_____________________ _____ ____________________ __________\_ / +\__ ____/\_ ____ \ /____/ / _____ __ \ / ______/ // /___jp! +// __|___// | \// |// | \// | | \// \ /___ /_____ +/____ _____| __________ ___|__| ____| \ / _____ \ +---- \______\ -- |______\ ------ /______/ ---- |______\ - |______\ /__/ // ___/ + /__ _\ + <*> ENiGMA½ // HTTPS://GITHUB.COM/NUSKOOLER/ENIGMA-BBS <*> /__/ + +------------------------------------------------------------------------------- diff --git a/misc/menu_template.in.hjson b/misc/menu_template.in.hjson index 8f1aca3b..6e1d889d 100644 --- a/misc/menu_template.in.hjson +++ b/misc/menu_template.in.hjson @@ -145,6 +145,9 @@ next: fullLoginSequenceLoginArt config: { tooNodeMenu: loginAttemptTooNode + inactive: loginAttemptAccountInactive + disabled: loginAttemptAccountDisabled + locked: loginAttemptAccountLocked } form: { 0: { @@ -185,6 +188,34 @@ cls: true nextTimeout: 2000 } + next: logoff + } + + loginAttemptAccountLocked: { + art: ACCOUNTLOCKED + config: { + cls: true + nextTimeout: 2000 + } + next: logoff + } + + loginAttemptAccountDisabled: { + art: ACCOUNTDISABLED + config: { + cls: true + nextTimeout: 2000 + } + next: logoff + } + + loginAttemptAccountInactive: { + art: ACCOUNTINACTIVE + config: { + cls: true + nextTimeout: 2000 + } + next: logoff } forgotPassword: { @@ -1921,7 +1952,6 @@ SM2: { argName: targetSelection submit: false - justify: right } } submit: { diff --git a/package-lock.json b/package-lock.json index be373f20..a3bc6f81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -94,11 +94,6 @@ "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz", "integrity": "sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=" }, - "arrify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", - "integrity": "sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0=" - }, "asn1": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.3.tgz", @@ -494,16 +489,15 @@ } }, "del": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/del/-/del-2.2.2.tgz", - "integrity": "sha1-wSyYHQZ4RshLyvhiz/kw2Qf/0ag=", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/del/-/del-3.0.0.tgz", + "integrity": "sha1-U+z2mf/LyzljdpGrE7rxYIGXZuU=", "requires": { - "globby": "^5.0.0", + "globby": "^6.1.0", "is-path-cwd": "^1.0.0", "is-path-in-cwd": "^1.0.0", - "object-assign": "^4.0.1", - "pify": "^2.0.0", - "pinkie-promise": "^2.0.0", + "p-map": "^1.1.1", + "pify": "^3.0.0", "rimraf": "^2.2.8" } }, @@ -878,16 +872,22 @@ } }, "globby": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-5.0.0.tgz", - "integrity": "sha1-69hGZ8oNuzMLmbz8aOrCvFQ3Dg0=", + "version": "6.1.0", + "resolved": "http://registry.npmjs.org/globby/-/globby-6.1.0.tgz", + "integrity": "sha1-9abXDoOV4hyFj7BInWTfAkJNUGw=", "requires": { "array-union": "^1.0.1", - "arrify": "^1.0.0", "glob": "^7.0.3", "object-assign": "^4.0.1", "pify": "^2.0.0", "pinkie-promise": "^2.0.0" + }, + "dependencies": { + "pify": { + "version": "2.3.0", + "resolved": "http://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=" + } } }, "graceful-fs": { @@ -954,9 +954,9 @@ "integrity": "sha512-U/fnTE3edW0AV92ZI/BfEluMZuVcu3MDOopsN7jS+HqDYcarQo8rXQiWlsBlm0uX48/taYSdxRsfzh2HRg5Z6w==" }, "hjson": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/hjson/-/hjson-3.1.1.tgz", - "integrity": "sha512-1oGkOq4sssz7HFZ8Is9HuTR47r8gSC46qAzQxVlAkj0lNKpS+W5Lv2eci+c5+fFqL+Idtj5EvprFreUwH29a8A==" + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/hjson/-/hjson-3.1.2.tgz", + "integrity": "sha512-2ILrho8eRl2Bniy61mDFiXRAloYqH2T6OwWkoF/8y55DPFgG2RcqQGNXIfBLp432dnAbLOpBJ4pJs63W3X27EA==" }, "http-signature": { "version": "1.2.0", @@ -1649,6 +1649,11 @@ "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=" }, + "p-map": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-1.2.0.tgz", + "integrity": "sha512-r6zKACMNhjPJMTl8KcFH4li//gkrXWfbD6feV8l6doRHlzljFWGJ2AP6iKaCJXyZmAUMOPtvbW7EXkbWO/pLEA==" + }, "pascalcase": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz", @@ -1675,9 +1680,9 @@ "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" }, "pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=" + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=" }, "pinkie": { "version": "2.0.4", @@ -2233,11 +2238,11 @@ } }, "temptmp": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/temptmp/-/temptmp-1.0.0.tgz", - "integrity": "sha1-M7Djbh8nMXyKKBIO6Wufj+tw2UM=", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/temptmp/-/temptmp-1.1.0.tgz", + "integrity": "sha512-gHelQlePUzxRmodWL1uJ9LiwI+a7a3rkFGS9azTf4noPZgGOlx0dOPV9tZs5+QwGc4Nm8BfFxL9cfvV42GNxPQ==", "requires": { - "del": "^2.2.2" + "del": "^3.0.0" } }, "through": { @@ -2493,9 +2498,9 @@ "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" }, "ws": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-6.1.0.tgz", - "integrity": "sha512-H3dGVdGvW2H8bnYpIDc3u3LH8Wue3Qh+Zto6aXXFzvESkTVT6rAfKR6tR/+coaUvxs8yHtmNV0uioBF62ZGSTg==", + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-6.1.2.tgz", + "integrity": "sha512-rfUqzvz0WxmSXtJpPMX2EeASXabOrSMk1ruMOV3JBTBjo4ac2lDjGGsbQSyxj8Odhw5fBib8ZKEjDNvgouNKYw==", "requires": { "async-limiter": "~1.0.0" } @@ -2514,9 +2519,9 @@ "integrity": "sha1-hFK0u36Dx8GI2AQcGoN8dz1ti7k=" }, "yazl": { - "version": "2.4.3", - "resolved": "https://registry.npmjs.org/yazl/-/yazl-2.4.3.tgz", - "integrity": "sha1-7CblzIfVYBud+EMtvdPNLlFzoHE=", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/yazl/-/yazl-2.5.0.tgz", + "integrity": "sha512-rgptqKwX/f1/7bIRF1FHb4HGsP5k11QyxBpDl1etUDfNpTa7CNjDOYNPFnIaEzZ9dRq0c47IEJS+sy+T39JCLw==", "requires": { "buffer-crc32": "~0.2.3" } diff --git a/package.json b/package.json index 73fd9ebc..f6425f01 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "glob": "^7.1.2", "graceful-fs": "^4.1.15", "hashids": "^1.1.1", - "hjson": "^3.1.1", + "hjson": "^3.1.2", "iconv-lite": "^0.4.23", "inquirer": "^6.0.0", "later": "1.2.0", @@ -47,12 +47,12 @@ "sqlite3": "^4.0.4", "sqlite3-trans": "^1.2.0", "ssh2": "^0.6.1", - "temptmp": "^1.0.0", + "temptmp": "^1.1.0", "uuid": "^3.2.1", "uuid-parse": "^1.0.0", - "ws": "^6.1.0", + "ws": "^6.1.2", "xxhash": "^0.2.4", - "yazl": "^2.4.2" + "yazl": "^2.5.0" }, "devDependencies": {}, "engines": {