diff --git a/UPGRADE.md b/UPGRADE.md index 800e6fd2..93c7b1d4 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,6 +1,8 @@ # Introduction This document covers basic upgrade notes for major ENiGMA½ version updates. +> :information_source: Be sure to read the version-to-version upgrade notes below for each upgrade! + # Before Upgrading * Always back up your system! (See [Administration](./docs/admin/administration.md)) * Seriously, always back up your system! @@ -30,11 +32,12 @@ Report your issue on Xibalba BBS, hop in #enigma-bbs on FreeNode and chat, or # 0.0.12-beta to 0.0.13-beta +* To enable the new Waiting for Caller (WFC) support, please see [WFC](docs/modding/wfc.md). * :exclamation: The SSH server's `ssh2` module has gone through a major upgrade. Existing users will need to comment out two SSH KEX algorithms from their `config.hjson` if present else clients such as NetRunner will not be able to connect over SSH. Comment out `diffie-hellman-group-exchange-sha256` and `diffie-hellman-group-exchange-sha1` * All features and changes are backwards compatible. There are a few new configuration options in a new `term` section in the configuration. These are all optional, but include the following options in case you use them: ```hjson -{ +{ term: { // checkUtf8Encoding requires the use of cursor position reports, which are not supported on all terminals. diff --git a/WHATSNEW.md b/WHATSNEW.md index b4b2a08f..ed9d959f 100644 --- a/WHATSNEW.md +++ b/WHATSNEW.md @@ -5,7 +5,11 @@ This document attempts to track **major** changes and additions in ENiGMA½. For * **Note for contributors**: ENiGMA has switched to [Prettier](https://prettier.io) for formatting/style. Please see [CONTRIBUTING](CONTRIBUTING.md) and the Prettier website for more information. * Removed terminal `cursor position reports` from most locations in the code. This should greatly increase the number of terminal programs that work with Enigma 1/2. For more information, see [Issue #222](https://github.com/NuSkooler/enigma-bbs/issues/222). This may also resolve other issues, such as [Issue #365](https://github.com/NuSkooler/enigma-bbs/issues/365), and [Issue #320](https://github.com/NuSkooler/enigma-bbs/issues/320). Anyone that previously had terminal incompatibilities please re-check and let us know! * Bumped up the minimum [Node.js](https://nodejs.org/en/) version to v14. This will allow more expressive Javascript programming syntax with ECMAScript 2020 to improve the development experience. +* **New Waiting For Caller (WFC)** support via the `wfc.js` module. * Added new configuration options for `term.checkUtf8Encoding`, `term.checkAnsiHomePostion`, `term.cp437TermList`, and `term.utf8TermList`. More information on these options is available in [UPGRADE](UPGRADE.md). +* Many new system statistics available via the StatLog such as current and average load, memory, etc. +* Many new MCI codes: `MB`, `MF`, `LA`, `CL`, `UU`, `FT`, `DD`, `FB`, `DB`, `LC`, `LT`, `LD`, and more. See [MCI](./docs/art/mci.md). +* SyncTERM style font support detection. * Added a system method to support setting the client encoding from menus, `@systemMethod:setClientEncoding`. * Many additional backward-compatible bug fixes since the first release of 0.0.12-beta. See the [project repository](https://github.com/NuSkooler/enigma-bbs) for more information. @@ -17,6 +21,7 @@ This document attempts to track **major** changes and additions in ENiGMA½. For * An explicit prompt file previously specified by `general.promptFile` in `config.hjson` is no longer necessary. Instead, this now simply part of the `prompts` section in `menu.hjson`. The default setup still creates a separate prompt HJSON file, but it is `includes`ed in `menu.hjson`. With the removal of prompts the `PromptsChanged` event will no longer be fired. * New `PV` ACS check for arbitrary user properties. See [ACS](./docs/configuration/acs.md) for details. * The `message` arg used by `msg_list` has been deprecated. Please starting using `messageIndex` for this purpose. Support for `message` will be removed in the future. +* A number of new MCI codes (see [MCI](./docs/art/mci.md)) * Added ability to export/download messages. This is enabled in the default menu. See `messageAreaViewPost` in [the default message base template](./misc/menu_templates/message_base.in.hjson) and look for the download options (`@method:addToDownloadQueue`, etc.) for details on adding to your system! * The Gopher server has had a revamp! Standard `gophermap` files are now served along with any other content you configure for your Gopher Hole! A default [gophermap](https://en.wikipedia.org/wiki/Gopher_(protocol)#Source_code_of_a_menu) can be found [in the misc directory](./misc/gophermap) that behaves like the previous implementation. See [Gopher docs](./docs/servers/gopher.md) for more information. * Default file browser up/down/pageUp/pageDown scrolls description (e.g. FILE_ID.DIZ). If you want to expose this on an existing system see the `fileBaseListEntries` in the default `file_base.in.hjson` template. diff --git a/art/themes/luciano_blocktronics/STATUS.ANS b/art/themes/luciano_blocktronics/STATUS.ANS index dc2b0ca8..e1618621 100644 Binary files a/art/themes/luciano_blocktronics/STATUS.ANS and b/art/themes/luciano_blocktronics/STATUS.ANS differ diff --git a/art/themes/luciano_blocktronics/theme.hjson b/art/themes/luciano_blocktronics/theme.hjson index 2ce388d0..8f6d01d7 100644 --- a/art/themes/luciano_blocktronics/theme.hjson +++ b/art/themes/luciano_blocktronics/theme.hjson @@ -246,6 +246,96 @@ } } + mainMenuWaitingForCaller: { + config: { + // formats + quickLogTimestampFormat: "|01|03MM|08/|03DD hh:mm:ssa" + nowDateTimeFormat: "|00|10ddd|08, |10MMMM Do YYYY|08, |10h|08:|10mm|02a" + lastLoginDateTimeFormat: "|00|10ddd hh|08:|10mm|02a" + + // header + mainInfoFormat10: "|00|10{now} |10{currentUserName} |08- |02Prv|08:|10{newPrivateMail} |02Addr|08:|10{newMessagesAddrTo} |08- |02Avail|08:|10{availIndicator} |02Vis|07:|10{visIndicator}" + + // today + mainInfoFormat11: "|00|15{callsToday:>5}" + mainInfoFormat12: "|00|15{postsToday:>5}" + mainInfoFormat13: "|00|15{newUsersToday:>5}" + mainInfoFormat14: "|00|15{uploadsToday:<4}" + mainInfoFormat15: "|00|15{downloadsToday:<4}" + mainInfoFormat16: "|00|15{uploadBytesToday!sizeWithoutAbbr:<5} |07{uploadBytesToday!sizeAbbr}" + mainInfoFormat17: "|00|15{downloadBytesToday!sizeWithoutAbbr:<5} |07{downloadBytesToday!sizeAbbr}" + + + // last login + mainInfoFormat18: "|00|15{lastLoginUserName:<26} |07{lastLogin}" + + // system stats + mainInfoFormat20: "|00|15{freeMemoryBytes!sizeWithoutAbbr} |07{freeMemoryBytes!sizeAbbr} free |08/ |15{totalMemoryBytes!sizeWithoutAbbr} |07{totalMemoryBytes!sizeAbbr}" + mainInfoFormat22: "|00|15{systemCurrentLoad} |07% |08/ |15{systemAvgLoad} |07load avg|08." + mainInfoFormat24: "|00|15{processUptimeSeconds!durationSeconds} |08/ |15{processBytesIngress!sizeWithoutAbbr:>4} |07{processBytesIngress!sizeAbbr}|08/|15{processBytesEgress!sizeWithoutAbbr:>4} |07{processBytesEgress!sizeAbbr}" + + // totals + mainInfoFormat19: "|00|15{totalCalls:>5}" + mainInfoFormat21: "|00|15{totalPosts:>7}" + mainInfoFormat23: "|00|15{totalUsers:>5}" + mainInfoFormat25: "|00|15{totalFiles:>4} |08/ |15{totalFileBytes!sizeWithoutAbbr:>4} |07{totalFileBytes!sizeAbbr}" + + quickLogLevel: info + quickLogLevelIndicators: { + trace : |00|02T + debug: |00|03D + info: |00|15I + warn: |00|14W + error: |00|12E + fatal: |00|28F + } + quickLogLevelMessagePrefixes: { + trace : |00|02 + debug: |00|03 + info: |00|07 + warn: |00|14 + error: |00|12 + fatal: |00|28 + } + statusAvailableIndicators: [ "N", "Y" ] + statusVisibleIndicators: [ "N", "Y" ] + + nodeStatusSelectionFormat: "|00|07{realName:<12}\n|08- |07{serverName:<10}\n|08- |07{remoteAddress:<10}" + } + 0: { + mci: { + TL16: { + fillChar: . + } + TL20: { width: 30 } + TL22: { width: 30 } + TL24: { width: 30 } + + // node status + VM1: { + height: 5 + width: 37 + itemFormat: "|00 |15{node:<3.2} |11{userName:<12} |07{action:<14.13} |15{serverName}" + focusItemFormat: "|00|10> |15{node:<3.2} |11{userName:<12} |07{action:<14.13} |15{serverName}" + focusItemAtTop: false + } + // quick log + VM2: { + height: 5 + width: 73 + itemFormat: "|00|07{nodeId} {levelIndicator} |02{timestamp} {message:<51.50}" + } + + MT3: { + mode: preview + autoScroll: false + height: 5 + width: 12 + } + } + } + } + messageBaseMessageList: { config: { dateTimeFormat: ddd MMM Do diff --git a/art/themes/luciano_blocktronics/wfc.ans b/art/themes/luciano_blocktronics/wfc.ans new file mode 100644 index 00000000..9bf19ca2 Binary files /dev/null and b/art/themes/luciano_blocktronics/wfc.ans differ diff --git a/art/themes/luciano_blocktronics/wfckicknodeprompt.ans b/art/themes/luciano_blocktronics/wfckicknodeprompt.ans new file mode 100644 index 00000000..a11d90eb --- /dev/null +++ b/art/themes/luciano_blocktronics/wfckicknodeprompt.ans @@ -0,0 +1 @@ +>> kick node? %TM1%TM1 diff --git a/core/abracadabra.js b/core/abracadabra.js index a1e12e99..2a9dd035 100644 --- a/core/abracadabra.js +++ b/core/abracadabra.js @@ -108,7 +108,7 @@ exports.getModule = class AbracadabraModule extends MenuModule { name: self.config.name, activeCount: activeDoorNodeInstances[self.config.name], }, - 'Too many active instances' + `Too many active instances of door "${self.config.name}"` ); if (_.isString(self.config.tooManyArt)) { @@ -179,6 +179,7 @@ exports.getModule = class AbracadabraModule extends MenuModule { this.client.term.write(ansi.resetScreen()); const exeInfo = { + name: this.config.name, cmd: this.config.cmd, cwd: this.config.cwd || paths.dirname(this.config.cmd), args: this.config.args, diff --git a/core/bbs.js b/core/bbs.js index b1e1a670..211311cf 100644 --- a/core/bbs.js +++ b/core/bbs.js @@ -2,9 +2,6 @@ /* eslint-disable no-console */ 'use strict'; -//var SegfaultHandler = require('segfault-handler'); -//SegfaultHandler.registerHandler('enigma-bbs-segfault.log'); - // ENiGMA½ const conf = require('./config.js'); const logger = require('./logger.js'); @@ -13,6 +10,7 @@ const resolvePath = require('./misc_util.js').resolvePath; const UserProps = require('./user_property.js'); const SysProps = require('./system_property.js'); const SysLogKeys = require('./system_log.js'); +const UserLogNames = require('./user_log_name'); // deps const async = require('async'); @@ -151,7 +149,9 @@ function shutdownSystem() { [ function closeConnections(callback) { const ClientConns = require('./client_connections.js'); - const activeConnections = ClientConns.getActiveConnections(); + const activeConnections = ClientConns.getActiveConnections( + ClientConns.AllConnections + ); let i = activeConnections.length; while (i--) { const activeTerm = activeConnections[i].term; @@ -257,6 +257,8 @@ function initialize(cb) { // const User = require('./user.js'); + // :TODO: use User.getUserInfo() for this! + const propLoadOpts = { names: [ UserProps.RealName, @@ -270,7 +272,7 @@ function initialize(cb) { async.waterfall( [ function getOpUserName(next) { - return User.getUserName(1, next); + return User.getUserName(User.RootUserID, next); }, function getOpProps(opUserName, next) { User.loadProperties( @@ -301,8 +303,9 @@ function initialize(cb) { } ); }, - function initCallsToday(callback) { + function initSystemLogStats(callback) { const StatLog = require('./stat_log.js'); + const filter = { logName: SysLogKeys.UserLoginHistory, resultType: 'count', @@ -319,6 +322,89 @@ function initialize(cb) { return callback(null); }); }, + function initUserLogStats(callback) { + const StatLog = require('./stat_log'); + + const entries = [ + [UserLogNames.UlFiles, [SysProps.FileUlTodayCount, 'count']], + [UserLogNames.UlFileBytes, [SysProps.FileUlTodayBytes, 'obj']], + [UserLogNames.DlFiles, [SysProps.FileDlTodayCount, 'count']], + [UserLogNames.DlFileBytes, [SysProps.FileDlTodayBytes, 'obj']], + [UserLogNames.NewUser, [SysProps.NewUsersTodayCount, 'count']], + ]; + + async.each( + entries, + (entry, nextEntry) => { + const [logName, [sysPropName, resultType]] = entry; + + const filter = { + logName, + resultType, + date: moment(), + }; + + StatLog.findUserLogEntries(filter, (err, stat) => { + if (!err) { + if (resultType === 'obj') { + stat = stat.reduce( + (bytes, entry) => + bytes + parseInt(entry.log_value) || 0, + 0 + ); + } + + StatLog.setNonPersistentSystemStat(sysPropName, stat); + } + return nextEntry(null); + }); + }, + () => { + return callback(null); + } + ); + }, + function initLastLogin(callback) { + const StatLog = require('./stat_log'); + StatLog.getSystemLogEntries( + SysLogKeys.UserLoginHistory, + 'timestamp_desc', + 1, + (err, lastLogin) => { + if (err) { + return callback(null); + } + + let loginObj; + try { + loginObj = JSON.parse(lastLogin[0].log_value); + loginObj.timestamp = moment(lastLogin[0].timestamp); + } catch (e) { + return callback(null); + } + + // For live stats we want to resolve user ID -> name, etc. + const User = require('./user'); + User.getUserInfo(loginObj.userId, (err, props) => { + const stat = Object.assign({}, props, loginObj); + StatLog.setNonPersistentSystemStat(SysProps.LastLogin, stat); + return callback(null); + }); + } + ); + }, + function initUserCount(callback) { + const User = require('./user.js'); + User.getUserCount((err, count) => { + if (err) { + return callback(err); + } + + const StatLog = require('./stat_log'); + StatLog.setNonPersistentSystemStat(SysProps.TotalUserCount, count); + return callback(null); + }); + }, function initMessageStats(callback) { return require('./message_area.js').startup(callback); }, diff --git a/core/bbs_link.js b/core/bbs_link.js index b5258123..17317435 100644 --- a/core/bbs_link.js +++ b/core/bbs_link.js @@ -129,7 +129,7 @@ exports.getModule = class BBSLinkModule extends MenuModule { '/auth.php?key=' + randomKey, headers, function resp(err, body) { - var status = body.trim(); + const status = body.trim(); if ('complete' === status) { return callback(null); diff --git a/core/client.js b/core/client.js index 3d900b66..d40c867d 100644 --- a/core/client.js +++ b/core/client.js @@ -97,7 +97,12 @@ function Client(/*input, output*/) { Object.defineProperty(this, 'currentTheme', { get: () => { if (this.currentThemeConfig) { - return this.currentThemeConfig.get(); + // :TODO: clean this up: We have a ugly transition state in which we have a pure raw config vs a ConfigLoader in which get() must be called + try { + return this.currentThemeConfig.get(); + } catch (e) { + return this.currentThemeConfig; + } } else { return { info: { @@ -592,6 +597,11 @@ Client.prototype.isLocal = function () { return ['127.0.0.1', '::ffff:127.0.0.1'].includes(this.remoteAddress); }; +Client.prototype.friendlyRemoteAddress = function () { + // convert any :ffff: IPv4's to 32bit version + return this.remoteAddress.replace(/^::ffff:/, '').replace(/^::1$/, 'localhost'); +}; + /////////////////////////////////////////////////////////////////////////////// // Default error handlers /////////////////////////////////////////////////////////////////////////////// diff --git a/core/client_connections.js b/core/client_connections.js index 99f81cf2..796c5bf5 100644 --- a/core/client_connections.js +++ b/core/client_connections.js @@ -21,31 +21,72 @@ exports.getConnectionByNodeId = getConnectionByNodeId; const clientConnections = []; exports.clientConnections = clientConnections; -function getActiveConnections(authUsersOnly = false) { +const AllConnections = { authUsersOnly: false, visibleOnly: false, availOnly: false }; +exports.AllConnections = AllConnections; + +const UserVisibleConnections = { + authUsersOnly: false, + visibleOnly: true, + availOnly: false, +}; +exports.UserVisibleConnections = UserVisibleConnections; + +const UserMessageableConnections = { + authUsersOnly: true, + visibleOnly: true, + availOnly: true, +}; +exports.UserMessageableConnections = UserMessageableConnections; + +function getActiveConnections( + options = { authUsersOnly: true, visibleOnly: true, availOnly: false } +) { return clientConnections.filter(conn => { - return (authUsersOnly && conn.user.isAuthenticated()) || !authUsersOnly; + if (options.authUsersOnly && !conn.user.isAuthenticated()) { + return false; + } + if (options.visibleOnly && !conn.user.isVisible()) { + return false; + } + if (options.availOnly && !conn.user.isAvailable()) { + return false; + } + + return true; }); } -function getActiveConnectionList(authUsersOnly) { - if (!_.isBoolean(authUsersOnly)) { - authUsersOnly = true; - } - +function getActiveConnectionList( + options = { authUsersOnly: true, visibleOnly: true, availOnly: false } +) { const now = moment(); - return _.map(getActiveConnections(authUsersOnly), ac => { + return _.map(getActiveConnections(options), ac => { + let action; + try { + // attempting to fetch a bad menu stack item can blow up/assert + action = _.get(ac, 'currentMenuModule.menuConfig.desc', 'Unknown'); + } catch (e) { + action = 'Unknown'; + } + const entry = { node: ac.node, authenticated: ac.user.isAuthenticated(), userId: ac.user.userId, - action: _.get(ac, 'currentMenuModule.menuConfig.desc', 'Unknown'), + action: action, + serverName: ac.session.serverName, + isSecure: ac.session.isSecure, + isVisible: ac.user.isVisible(), + isAvailable: ac.user.isAvailable(), + remoteAddress: ac.friendlyRemoteAddress(), }; // // There may be a connection, but not a logged in user as of yet // if (ac.user.isAuthenticated()) { + entry.text = ac.user.username; entry.userName = ac.user.username; entry.realName = ac.user.properties[UserProps.RealName]; entry.location = ac.user.properties[UserProps.Location]; @@ -57,6 +98,7 @@ function getActiveConnectionList(authUsersOnly) { ); entry.timeOn = moment.duration(diff, 'minutes'); } + return entry; }); } @@ -81,6 +123,15 @@ function addNewClient(client, clientSock) { moment().valueOf(), ]); + // kludge to refresh process update stats at first client + if (clientConnections.length < 1) { + setTimeout(() => { + const StatLog = require('./stat_log'); + const SysProps = require('./system_property'); + StatLog.getSystemStat(SysProps.ProcessTrafficStats); + }, 3000); // slight pause to wait for updates + } + clientConnections.push(client); clientConnections.sort((c1, c2) => c1.session.id - c2.session.id); @@ -90,6 +141,7 @@ function addNewClient(client, clientSock) { const connInfo = { remoteAddress: remoteAddress, + friendlyRemoteAddress: client.friendlyRemoteAddress(), serverName: client.session.serverName, isSecure: client.session.isSecure, }; @@ -99,7 +151,10 @@ function addNewClient(client, clientSock) { connInfo.family = clientSock.localFamily; } - client.log.info(connInfo, 'Client connected'); + client.log.info( + connInfo, + `Client connected (${connInfo.serverName}/${connInfo.port})` + ); Events.emit(Events.getSystemEvents().ClientConnected, { client: client, @@ -143,9 +198,9 @@ function removeClient(client) { } function getConnectionByUserId(userId) { - return getActiveConnections().find(ac => userId === ac.user.userId); + return getActiveConnections(AllConnections).find(ac => userId === ac.user.userId); } function getConnectionByNodeId(nodeId) { - return getActiveConnections().find(ac => nodeId == ac.node); + return getActiveConnections(AllConnections).find(ac => nodeId == ac.node); } diff --git a/core/client_term.js b/core/client_term.js index 253e5dd3..8a546f92 100644 --- a/core/client_term.js +++ b/core/client_term.js @@ -2,19 +2,19 @@ 'use strict'; // ENiGMA½ -var Log = require('./logger.js').log; -var renegadeToAnsi = require('./color_codes.js').renegadeToAnsi; +const Log = require('./logger.js').log; +const renegadeToAnsi = require('./color_codes.js').renegadeToAnsi; const Config = require('./config.js').get; -var iconv = require('iconv-lite'); -var assert = require('assert'); -var _ = require('lodash'); +const iconv = require('iconv-lite'); +const assert = require('assert'); +const _ = require('lodash'); exports.ClientTerminal = ClientTerminal; function ClientTerminal(output) { this.output = output; - var outputEncoding = 'cp437'; + let outputEncoding = 'cp437'; assert(iconv.encodingExists(outputEncoding)); // convert line feeds such as \n -> \r\n @@ -26,10 +26,10 @@ function ClientTerminal(output) { // Some terminal we handle specially // They can also be found in this.env{} // - var termType = 'unknown'; - var termHeight = 0; - var termWidth = 0; - var termClient = 'unknown'; + let termType = 'unknown'; + let termHeight = 0; + let termWidth = 0; + let termClient = 'unknown'; this.currentSyncFont = 'not_set'; diff --git a/core/config_loader.js b/core/config_loader.js index c84f0186..be14776f 100644 --- a/core/config_loader.js +++ b/core/config_loader.js @@ -69,7 +69,7 @@ module.exports = class ConfigLoader { defaultConfig, config, (defaultVal, configVal, key, target, source) => { - var path; + let path; while (true) { // eslint-disable-line no-constant-condition if (!stack.length) { diff --git a/core/connect.js b/core/connect.js index 83b6348e..71b41582 100644 --- a/core/connect.js +++ b/core/connect.js @@ -157,9 +157,7 @@ const ansiQuerySyncTermFontSupport = (client, cb) => { const [_, w] = pos; if (w === 1) { // cursor didn't move - client.log.info( - 'Client supports SyncTERM fonts or properly ignores unknown ESC sequence' - ); + client.log.info('Enabling SyncTERM font support'); client.term.syncTermFontsEnabled = true; } }, diff --git a/core/door.js b/core/door.js index 8474f762..305ae162 100644 --- a/core/door.js +++ b/core/door.js @@ -32,7 +32,7 @@ module.exports = class Door { }); conn.once('error', err => { - this.client.log.info( + this.client.log.warn( { error: err.message }, 'Door socket server connection' ); @@ -77,7 +77,7 @@ module.exports = class Door { this.client.log.info( { cmd: exeInfo.cmd, args, io: this.io }, - 'Executing external door process' + `Executing external door (${exeInfo.name})` ); try { diff --git a/core/event_scheduler.js b/core/event_scheduler.js index 5c7f4150..076e116f 100644 --- a/core/event_scheduler.js +++ b/core/event_scheduler.js @@ -114,7 +114,7 @@ class ScheduledEvent { executeAction(reason, cb) { Log.info( { eventName: this.name, action: this.action, reason: reason }, - 'Executing scheduled event action...' + `Executing scheduled event "${this.name}"...` ); if ('method' === this.action.type) { diff --git a/core/file_area_list.js b/core/file_area_list.js index 52f14fc2..77b079ff 100644 --- a/core/file_area_list.js +++ b/core/file_area_list.js @@ -344,74 +344,17 @@ exports.getModule = class FileAreaList extends MenuModule { ); } - displayArtAndPrepViewController(name, options, cb) { - const self = this; - const config = this.menuConfig.config; - - async.waterfall( - [ - function readyAndDisplayArt(callback) { - if (options.clearScreen) { - self.client.term.rawWrite(ansi.resetScreen()); - } - - theme.displayThemedAsset( - config.art[name], - self.client, - { font: self.menuConfig.font, trailingLF: false }, - (err, artData) => { - return callback(err, artData); - } - ); - }, - function prepeareViewController(artData, callback) { - if (_.isUndefined(self.viewControllers[name])) { - const vcOpts = { - client: self.client, - formId: FormIds[name], - }; - - if (!_.isUndefined(options.noInput)) { - vcOpts.noInput = options.noInput; - } - - const vc = self.addViewController( - name, - new ViewController(vcOpts) - ); - - if ('details' === name) { - try { - self.detailsInfoArea = { - top: artData.mciMap.XY2.position, - bottom: artData.mciMap.XY3.position, - }; - } catch (e) { - return callback( - Errors.DoesNotExist( - 'Missing XY2 and XY3 position indicators!' - ) - ); - } - } - - const loadOpts = { - callingMenu: self, - mciMap: artData.mciMap, - formId: FormIds[name], - }; - - return vc.loadFromMenuConfig(loadOpts, callback); - } - - self.viewControllers[name].setFocus(true); - return callback(null); - }, - ], - err => { - return cb(err); + displayArtDataPrepCallback(name, artData, viewController) { + if ('details' === name) { + try { + this.detailsInfoArea = { + top: artData.mciMap.XY2.position, + bottom: artData.mciMap.XY3.position, + }; + } catch (e) { + throw Errors.DoesNotExist('Missing XY2 and XY3 position indicators!'); } - ); + } } displayBrowsePage(clearScreen, cb) { @@ -436,7 +379,11 @@ exports.getModule = class FileAreaList extends MenuModule { function prepArtAndViewController(callback) { return self.displayArtAndPrepViewController( 'browse', - { clearScreen: clearScreen }, + FormIds.browse, + { + clearScreen: clearScreen, + artDataPrep: self.displayArtDataPrepCallback.bind(self), + }, callback ); }, @@ -528,7 +475,11 @@ exports.getModule = class FileAreaList extends MenuModule { function prepArtAndViewController(callback) { return self.displayArtAndPrepViewController( 'details', - { clearScreen: true }, + FormIds.details, + { + clearScreen: true, + artDataPrep: self.displayArtDataPrepCallback.bind(self), + }, callback ); }, @@ -778,7 +729,12 @@ exports.getModule = class FileAreaList extends MenuModule { return self.displayArtAndPrepViewController( name, - { clearScreen: false, noInput: true }, + FormIds[name], + { + clearScreen: false, + noInput: true, + artDataPrep: self.displayArtDataPrepCallback.bind(self), + }, callback ); }, diff --git a/core/file_area_web.js b/core/file_area_web.js index 17daa3a0..4ffe2491 100644 --- a/core/file_area_web.js +++ b/core/file_area_web.js @@ -534,6 +534,12 @@ class FileAreaWebAccess { StatLog.incrementSystemStat(SysProps.FileDlTotalCount, 1); StatLog.incrementSystemStat(SysProps.FileDlTotalBytes, dlBytes); + StatLog.incrementNonPersistentSystemStat(SysProps.FileDlTodayCount, 1); + StatLog.incrementNonPersistentSystemStat( + SysProps.FileDlTodayBytes, + dlBytes + ); + return callback(null, user); }, function sendEvent(user, callback) { diff --git a/core/file_base_download_manager.js b/core/file_base_download_manager.js index 5d232365..b8564b73 100644 --- a/core/file_base_download_manager.js +++ b/core/file_base_download_manager.js @@ -194,6 +194,7 @@ exports.getModule = class FileBaseDownloadQueueManager extends MenuModule { function prepArtAndViewController(callback) { return self.displayArtAndPrepViewController( 'queueManager', + FormIds.queueManager, { clearScreen: clearScreen }, callback ); @@ -209,59 +210,4 @@ exports.getModule = class FileBaseDownloadQueueManager extends MenuModule { } ); } - - displayArtAndPrepViewController(name, options, cb) { - const self = this; - const config = this.menuConfig.config; - - async.waterfall( - [ - function readyAndDisplayArt(callback) { - if (options.clearScreen) { - self.client.term.rawWrite(ansi.resetScreen()); - } - - theme.displayThemedAsset( - config.art[name], - self.client, - { font: self.menuConfig.font, trailingLF: false }, - (err, artData) => { - return callback(err, artData); - } - ); - }, - function prepeareViewController(artData, callback) { - if (_.isUndefined(self.viewControllers[name])) { - const vcOpts = { - client: self.client, - formId: FormIds[name], - }; - - if (!_.isUndefined(options.noInput)) { - vcOpts.noInput = options.noInput; - } - - const vc = self.addViewController( - name, - new ViewController(vcOpts) - ); - - const loadOpts = { - callingMenu: self, - mciMap: artData.mciMap, - formId: FormIds[name], - }; - - return vc.loadFromMenuConfig(loadOpts, callback); - } - - self.viewControllers[name].setFocus(true); - return callback(null); - }, - ], - err => { - return cb(err); - } - ); - } }; diff --git a/core/file_base_web_download_manager.js b/core/file_base_web_download_manager.js index 61ff1871..233a247e 100644 --- a/core/file_base_web_download_manager.js +++ b/core/file_base_web_download_manager.js @@ -187,6 +187,7 @@ exports.getModule = class FileBaseWebDownloadQueueManager extends MenuModule { function prepArtAndViewController(callback) { return self.displayArtAndPrepViewController( 'queueManager', + FormIds.queueManager, { clearScreen: clearScreen }, callback ); @@ -266,59 +267,4 @@ exports.getModule = class FileBaseWebDownloadQueueManager extends MenuModule { } ); } - - displayArtAndPrepViewController(name, options, cb) { - const self = this; - const config = this.menuConfig.config; - - async.waterfall( - [ - function readyAndDisplayArt(callback) { - if (options.clearScreen) { - self.client.term.rawWrite(ansi.resetScreen()); - } - - theme.displayThemedAsset( - config.art[name], - self.client, - { font: self.menuConfig.font, trailingLF: false }, - (err, artData) => { - return callback(err, artData); - } - ); - }, - function prepeareViewController(artData, callback) { - if (_.isUndefined(self.viewControllers[name])) { - const vcOpts = { - client: self.client, - formId: FormIds[name], - }; - - if (!_.isUndefined(options.noInput)) { - vcOpts.noInput = options.noInput; - } - - const vc = self.addViewController( - name, - new ViewController(vcOpts) - ); - - const loadOpts = { - callingMenu: self, - mciMap: artData.mciMap, - formId: FormIds[name], - }; - - return vc.loadFromMenuConfig(loadOpts, callback); - } - - self.viewControllers[name].setFocus(true); - return callback(null); - }, - ], - err => { - return cb(err); - } - ); - } }; diff --git a/core/file_transfer.js b/core/file_transfer.js index 631fa325..af166d24 100644 --- a/core/file_transfer.js +++ b/core/file_transfer.js @@ -150,7 +150,7 @@ exports.getModule = class TransferFileModule extends MenuModule { this.client.log.info( { sentFiles: sentFiles }, - `Successfully sent ${sentFiles.length} file(s)` + `User "${self.client.user.username}" uploaded ${sentFiles.length} file(s)` ); } return cb(err); diff --git a/core/login_server_module.js b/core/login_server_module.js index 95eaebae..5f74801f 100644 --- a/core/login_server_module.js +++ b/core/login_server_module.js @@ -52,6 +52,7 @@ module.exports = class LoginServerModule extends ServerModule { client.session = {}; } + client.rawSocket = clientSock; client.session.serverName = modInfo.name; client.session.isSecure = _.isBoolean(client.isSecure) ? client.isSecure diff --git a/core/menu_module.js b/core/menu_module.js index d7c23dbb..eb98ef29 100644 --- a/core/menu_module.js +++ b/core/menu_module.js @@ -12,6 +12,7 @@ const MultiLineEditTextView = require('../core/multi_line_edit_text_view.js').MultiLineEditTextView; const Errors = require('../core/enig_error.js').Errors; const { getPredefinedMCIValue } = require('../core/predefined_mci.js'); +const EnigAssert = require('./enigma_assert'); // deps const async = require('async'); @@ -574,8 +575,13 @@ exports.MenuModule = class MenuModule extends PluginModule { } } - //let artHeight; + const originalSubmitNotify = options.submitNotify; + options.submitNotify = () => { + if (_.isFunction(originalSubmitNotify)) { + originalSubmitNotify(); + } + if (prevVc) { prevVc.setFocus(true); } @@ -596,6 +602,9 @@ exports.MenuModule = class MenuModule extends PluginModule { options.viewController.setFocus(true); this.optionalMoveToPosition(position); + if (!options.position) { + options.position = position; + } theme.displayThemedPrompt(promptName, this.client, options, (err, artInfo) => { /* if(artInfo) { @@ -606,6 +615,69 @@ exports.MenuModule = class MenuModule extends PluginModule { }); } + displayArtAndPrepViewController(name, formId, options, cb) { + const config = this.menuConfig.config; + EnigAssert(_.isObject(config)); + + async.waterfall( + [ + callback => { + if (options.clearScreen) { + this.client.term.rawWrite(ansi.resetScreen()); + } + + theme.displayThemedAsset( + config.art[name], + this.client, + { font: this.menuConfig.font, trailingLF: false }, + (err, artData) => { + return callback(err, artData); + } + ); + }, + (artData, callback) => { + if (_.isUndefined(this.viewControllers[name])) { + const vcOpts = { + client: this.client, + formId: formId, + }; + + if (!_.isUndefined(options.noInput)) { + vcOpts.noInput = options.noInput; + } + + const vc = this.addViewController( + name, + new ViewController(vcOpts) + ); + + if (_.isFunction(options.artDataPrep)) { + try { + options.artDataPrep(name, artData, vc); + } catch (e) { + return callback(e); + } + } + + const loadOpts = { + callingMenu: this, + mciMap: artData.mciMap, + formId: formId, + }; + + return vc.loadFromMenuConfig(loadOpts, callback); + } + + this.viewControllers[name].setFocus(true); + return callback(null); + }, + ], + err => { + return cb(err); + } + ); + } + setViewText(formName, mciId, text, appendMultiLine) { const view = this.getView(formName, mciId); if (!view) { @@ -613,7 +685,7 @@ exports.MenuModule = class MenuModule extends PluginModule { } if (appendMultiLine && view instanceof MultiLineEditTextView) { - view.addText(text); + view.setAnsi(text); } else { view.setText(text); } @@ -650,7 +722,7 @@ exports.MenuModule = class MenuModule extends PluginModule { textView instanceof MultiLineEditTextView ) { textView.addText(text); - } else { + } else if (textView.getData() != text) { textView.setText(text); } } @@ -752,4 +824,26 @@ exports.MenuModule = class MenuModule extends PluginModule { ) ); } + + // Various common helpers + getDateFormat(defaultStyle = 'short') { + return ( + this.config.dateFormat || + this.client.currentTheme.helpers.getDateFormat(defaultStyle) + ); + } + + getTimeFormat(defaultStyle = 'short') { + return ( + this.config.timeFormat || + this.client.currentTheme.helpers.getTimeFormat(defaultStyle) + ); + } + + getDateTimeFormat(defaultStyle = 'short') { + return ( + this.config.dateTimeFormat || + this.client.currentTheme.helpers.getDateTimeFormat(defaultStyle) + ); + } }; diff --git a/core/menu_view.js b/core/menu_view.js index f16f696a..ed2eb9fb 100644 --- a/core/menu_view.js +++ b/core/menu_view.js @@ -198,6 +198,10 @@ MenuView.prototype.getItems = function () { }; MenuView.prototype.getItem = function (index) { + if (index > this.items.length - 1) { + return null; + } + if (this.complexItems) { return this.items[index]; } @@ -233,6 +237,10 @@ MenuView.prototype.setFocusItemIndex = function (index) { this.focusedItemIndex = index; }; +MenuView.prototype.getFocusItemIndex = function () { + return this.focusedItemIndex; +}; + MenuView.prototype.onKeyPress = function (ch, key) { const itemIndex = this.getHotKeyItemIndex(ch); if (itemIndex >= 0) { diff --git a/core/message_area.js b/core/message_area.js index 76687aae..32825c71 100644 --- a/core/message_area.js +++ b/core/message_area.js @@ -40,6 +40,7 @@ exports.filterMessageListByReadACS = filterMessageListByReadACS; exports.tempChangeMessageConfAndArea = tempChangeMessageConfAndArea; exports.getMessageListForArea = getMessageListForArea; exports.getNewMessageCountInAreaForUser = getNewMessageCountInAreaForUser; +exports.getNewMessageCountAddressedToUser = getNewMessageCountAddressedToUser; exports.getNewMessagesInAreaForUser = getNewMessagesInAreaForUser; exports.getMessageIdNewerThanTimestampByArea = getMessageIdNewerThanTimestampByArea; exports.getMessageAreaLastReadId = getMessageAreaLastReadId; @@ -531,6 +532,36 @@ function getNewMessageCountInAreaForUser(userId, areaTag, cb) { }); } +// New message count -- for all areas available to the user +// that are addressed to that user (ie: matching username) +// Does NOT Include private messages. +function getNewMessageCountAddressedToUser(client, cb) { + const areaTags = getAllAvailableMessageAreaTags(client).filter( + areaTag => areaTag !== Message.WellKnownAreaTags.Private + ); + + let newMessageCount = 0; + async.forEach( + areaTags, + (areaTag, nextAreaTag) => { + getMessageAreaLastReadId(client.user.userId, areaTag, (_, lastMessageId) => { + lastMessageId = lastMessageId || 0; + getNewMessageCountInAreaForUser( + client.user.userId, + areaTag, + (err, count) => { + newMessageCount += count; + return nextAreaTag(err); + } + ); + }); + }, + () => { + return cb(null, newMessageCount); + } + ); +} + function getNewMessagesInAreaForUser(userId, areaTag, cb) { getMessageAreaLastReadId(userId, areaTag, (err, lastMessageId) => { lastMessageId = lastMessageId || 0; diff --git a/core/misc_scheduled_events.js b/core/misc_scheduled_events.js index 12fa19bb..ba794eb8 100644 --- a/core/misc_scheduled_events.js +++ b/core/misc_scheduled_events.js @@ -10,7 +10,14 @@ function dailyMaintenanceScheduledEvent(args, cb) { // // Various stats need reset daily // - [SysProps.LoginsToday, SysProps.MessagesToday].forEach(prop => { + // :TODO: files/etc. here + const resetProps = [ + SysProps.LoginsToday, + SysProps.MessagesToday, + SysProps.NewUsersTodayCount, + ]; + + resetProps.forEach(prop => { StatLog.setNonPersistentSystemStat(prop, 0); }); diff --git a/core/msg_area_post_fse.js b/core/msg_area_post_fse.js index cac2700a..9eacd1f5 100644 --- a/core/msg_area_post_fse.js +++ b/core/msg_area_post_fse.js @@ -51,7 +51,7 @@ exports.getModule = class AreaPostFSEModule extends FullScreenEditorModule { subject: msg.subject, uuid: msg.messageUuid, }, - 'Message persisted' + `User "${self.client.user.username}" posted message to "${msg.toUserName}" (${msg.areaTag})` ); } diff --git a/core/node_msg.js b/core/node_msg.js index 58585382..71530f9c 100644 --- a/core/node_msg.js +++ b/core/node_msg.js @@ -6,6 +6,7 @@ const { MenuModule } = require('./menu_module.js'); const { getActiveConnectionList, getConnectionByNodeId, + UserMessageableConnections, } = require('./client_connections.js'); const UserInterruptQueue = require('./user_interrupt_queue.js'); const { getThemeArt } = require('./theme.js'); @@ -236,7 +237,7 @@ exports.getModule = class NodeMessageModule extends MenuModule { }, ] .concat( - getActiveConnectionList(true).map(node => + getActiveConnectionList(UserMessageableConnections).map(node => Object.assign(node, { text: -1 == node.node ? '-ALL-' : node.node.toString(), }) diff --git a/core/nua.js b/core/nua.js index 72cd6647..4f6f355d 100644 --- a/core/nua.js +++ b/core/nua.js @@ -130,7 +130,7 @@ exports.getModule = class NewUserAppModule extends MenuModule { }; newUser.create(createUserInfo, err => { if (err) { - self.client.log.info( + self.client.log.warn( { error: err, username: formData.value.username }, 'New user creation failed' ); @@ -144,7 +144,7 @@ exports.getModule = class NewUserAppModule extends MenuModule { } else { self.client.log.info( { username: formData.value.username, userId: newUser.userId }, - 'New user created' + `New user "${formData.value.username}" created` ); // Cache SysOp information now diff --git a/core/predefined_mci.js b/core/predefined_mci.js index 8ce1dcee..7463186f 100644 --- a/core/predefined_mci.js +++ b/core/predefined_mci.js @@ -19,14 +19,30 @@ const packageJson = require('../package.json'); const os = require('os'); const _ = require('lodash'); const moment = require('moment'); +const async = require('async'); exports.getPredefinedMCIValue = getPredefinedMCIValue; exports.init = init; function init(cb) { - setNextRandomRumor(cb); + async.series( + [ + callback => { + return setNextRandomRumor(callback); + }, + callback => { + // by fetching a memory or load we'll force a refresh now + StatLog.getSystemStat(SysProps.SystemMemoryStats); + return callback(null); + }, + ], + err => { + return cb(err); + } + ); } +// :TODO: move this to stat_log.js like system memory is handled function setNextRandomRumor(cb) { StatLog.getSystemLogEntries( SysLogKeys.UserAddedRumorz, @@ -65,10 +81,6 @@ function userStatAsCountString(client, statName, defaultValue) { return toNumberWithCommas(value); } -function sysStatAsString(statName, defaultValue) { - return (StatLog.getSystemStat(statName) || defaultValue).toLocaleString(); -} - const PREDEFINED_MCI_GENERATORS = { // // Board @@ -104,7 +116,6 @@ const PREDEFINED_MCI_GENERATORS = { SE: function opEmail() { return StatLog.getSystemStat(SysProps.SysOpEmailAddress); }, - // :TODO: op age, web, ????? // // Current user / session @@ -162,8 +173,8 @@ const PREDEFINED_MCI_GENERATORS = { return client.node.toString(); }, IP: function clientIpAddress(client) { - return client.remoteAddress.replace(/^::ffff:/, ''); - }, // convert any :ffff: IPv4's to 32bit version + return client.friendlyRemoteAddress(); + }, ST: function serverName(client) { return client.session.serverName; }, @@ -272,6 +283,23 @@ const PREDEFINED_MCI_GENERATORS = { const minutes = client.user.properties[UserProps.MinutesOnlineTotalCount] || 0; return moment.duration(minutes, 'minutes').humanize(); }, + NM: function userNewMessagesAddressedToCount(client) { + return StatLog.getUserStatNumByClient( + client, + UserProps.NewAddressedToMessageCount + ); + }, + NP: function userNewPrivateMailCount(client) { + return StatLog.getUserStatNumByClient(client, UserProps.NewPrivateMailCount); + }, + IA: function userStatusAvailableIndicator(client) { + const indicators = client.currentTheme.helpers.getStatusAvailIndicators(); + return client.user.isAvailable() ? indicators[0] || 'Y' : indicators[1] || 'N'; + }, + IV: function userStatusVisibleIndicator(client) { + const indicators = client.currentTheme.helpers.getStatusVisibleIndicators(); + return client.user.isVisible() ? indicators[0] || 'Y' : indicators[1] || 'N'; + }, // // Date/Time @@ -318,15 +346,36 @@ const PREDEFINED_MCI_GENERATORS = { .trim(); }, - // :TODO: MCI for core count, e.g. os.cpus().length - - // :TODO: cpu load average (over N seconds): http://stackoverflow.com/questions/9565912/convert-the-output-of-os-cpus-in-node-js-to-percentage + MB: function totalMemoryBytes() { + const stats = StatLog.getSystemStat(SysProps.SystemMemoryStats) || { + totalBytes: 0, + }; + return formatByteSize(stats.totalBytes, true); // true=withAbbr + }, + MF: function totalMemoryFreeBytes() { + const stats = StatLog.getSystemStat(SysProps.SystemMemoryStats) || { + freeBytes: 0, + }; + return formatByteSize(stats.freeBytes, true); // true=withAbbr + }, + LA: function systemLoadAverage() { + const stats = StatLog.getSystemStat(SysProps.SystemLoadStats) || { average: 0.0 }; + return stats.average.toLocaleString(); + }, + CL: function systemCurrentLoad() { + const stats = StatLog.getSystemStat(SysProps.SystemLoadStats) || { current: 0 }; + return `${stats.current}%`; + }, + UU: function systemUptime() { + return moment.duration(process.uptime(), 'seconds').humanize(); + }, NV: function nodeVersion() { return process.version; }, - AN: function activeNodes() { - return clientConnections.getActiveConnections().length.toString(); + return clientConnections + .getActiveConnections(clientConnections.UserVisibleConnections) + .length.toString(); }, TC: function totalCalls() { @@ -336,6 +385,19 @@ const PREDEFINED_MCI_GENERATORS = { return StatLog.getSystemStat(SysProps.LoginsToday).toLocaleString(); }, + PI: function processBytesIngress() { + const stats = StatLog.getSystemStat(SysProps.ProcessTrafficStats) || { + ingress: 0, + }; + return stats.ingress.toLocaleString(); + }, + PE: function processBytesEgress() { + const stats = StatLog.getSystemStat(SysProps.ProcessTrafficStats) || { + egress: 0, + }; + return stats.ingress.toLocaleString(); + }, + RR: function randomRumor() { // start the process of picking another random one setNextRandomRumor(); @@ -346,17 +408,15 @@ const PREDEFINED_MCI_GENERATORS = { // // System File Base, Up/Download Info // - // :TODO: DD - Today's # of downloads (iNiQUiTY) - // SD: function systemNumDownloads() { - return sysStatAsString(SysProps.FileDlTotalCount, 0); + return StatLog.getFriendlySystemStat(SysProps.FileDlTotalCount, 0); }, SO: function systemByteDownload() { const byteSize = StatLog.getSystemStatNum(SysProps.FileDlTotalBytes); return formatByteSize(byteSize, true); // true=withAbbr }, SU: function systemNumUploads() { - return sysStatAsString(SysProps.FileUlTotalCount, 0); + return StatLog.getFriendlySystemStat(SysProps.FileUlTotalCount, 0); }, SP: function systemByteUpload() { const byteSize = StatLog.getSystemStatNum(SysProps.FileUlTotalBytes); @@ -373,18 +433,59 @@ const PREDEFINED_MCI_GENERATORS = { }, PT: function messagesPostedToday() { // Obv/2 - return sysStatAsString(SysProps.MessagesToday, 0); + return StatLog.getFriendlySystemStat(SysProps.MessagesToday, 0); }, TP: function totalMessagesOnSystem() { // Obv/2 - return sysStatAsString(SysProps.MessageTotalCount, 0); + return StatLog.getFriendlySystemStat(SysProps.MessageTotalCount, 0); + }, + FT: function totalUploadsToday() { + // Obv/2 + return StatLog.getFriendlySystemStat(SysProps.FileUlTodayCount, 0); + }, + FB: function totalUploadBytesToday() { + const byteSize = StatLog.getSystemStatNum(SysProps.FileUlTodayBytes); + return formatByteSize(byteSize, true); // true=withAbbr + }, + DD: function totalDownloadsToday() { + // iNiQUiTY + return StatLog.getFriendlySystemStat(SysProps.FileDlTodayCount, 0); + }, + DB: function totalDownloadBytesToday() { + const byteSize = StatLog.getSystemStatNum(SysProps.FileDlTodayBytes); + return formatByteSize(byteSize, true); // true=withAbbr + }, + NT: function totalNewUsersToday() { + // Obv/2 + return StatLog.getSystemStatNum(SysProps.NewUsersTodayCount); }, - // :TODO: NT - New users today (Obv/2) - // :TODO: FT - Files uploaded/added *today* (Obv/2) - // :TODO: DD - Files downloaded *today* (iNiQUiTY) - // :TODO: LC - name of last caller to system (Obv/2) // :TODO: TZ - Average *system* post/call ratio (iNiQUiTY) + // :TODO: ?? - Total users on system + + TU: function totalSystemUsers() { + return StatLog.getSystemStatNum(SysProps.TotalUserCount) || 1; + }, + + LC: function lastCallerUserName() { + // Obv/2 + const lastLogin = StatLog.getSystemStat(SysProps.LastLogin) || {}; + return lastLogin.userName || 'N/A'; + }, + LD: function lastCallerDate(client) { + const lastLogin = StatLog.getSystemStat(SysProps.LastLogin) || {}; + if (!lastLogin.timestamp) { + return 'N/A'; + } + return lastLogin.timestamp.format(client.currentTheme.helpers.getDateFormat()); + }, + LT: function lastCallerTime(client) { + const lastLogin = StatLog.getSystemStat(SysProps.LastLogin) || {}; + if (!lastLogin.timestamp) { + return 'N/A'; + } + return lastLogin.timestamp.format(client.currentTheme.helpers.getTimeFormat()); + }, // // Special handling for XY @@ -424,7 +525,7 @@ function getPredefinedMCIValue(client, code, extra) { } catch (e) { Log.error( { code: code, exception: e.message }, - 'Exception caught generating predefined MCI value' + `Failed generating predefined MCI value (${code})` ); } diff --git a/core/stat_log.js b/core/stat_log.js index aa921c9b..f2d57bd0 100644 --- a/core/stat_log.js +++ b/core/stat_log.js @@ -4,10 +4,15 @@ const sysDb = require('./database.js').dbs.system; const { getISOTimestampString } = require('./database.js'); const Errors = require('./enig_error.js'); +const SysProps = require('./system_property.js'); +const UserProps = require('./user_property'); +const Message = require('./message'); +const { getActiveConnections, AllConnections } = require('./client_connections'); // deps const _ = require('lodash'); const moment = require('moment'); +const SysInfo = require('systeminformation'); /* System Event Log & Stats @@ -24,6 +29,7 @@ const moment = require('moment'); class StatLog { constructor() { this.systemStats = {}; + this.lastSysInfoStatsRefresh = 0; } init(cb) { @@ -106,7 +112,17 @@ class StatLog { } getSystemStat(statName) { - return this.systemStats[statName]; + const stat = this.systemStats[statName]; + + // Some stats are refreshed periodically when they are + // being accessed (e.g. "looked at"). This is handled async. + this._refreshSystemStat(statName); + + return stat; + } + + getFriendlySystemStat(statName, defaultValue) { + return (this.getSystemStat(statName) || defaultValue).toLocaleString(); } getSystemStatNum(statName) { @@ -141,13 +157,25 @@ class StatLog { } getUserStat(user, statName) { - return user.properties[statName]; + return user.getProperty(statName); + } + + getUserStatByClient(client, statName) { + const stat = this.getUserStat(client.user, statName); + this._refreshUserStat(client, statName); + return stat; } getUserStatNum(user, statName) { return parseInt(this.getUserStat(user, statName)) || 0; } + getUserStatNumByClient(client, statName, ttlSeconds = 10) { + const stat = this.getUserStatNum(client.user, statName); + this._refreshUserStat(client, statName, ttlSeconds); + return stat; + } + incrementUserStat(user, statName, incrementBy, cb) { incrementBy = incrementBy || 1; @@ -215,7 +243,7 @@ class StatLog { sysDb.run( `DELETE FROM system_event_log WHERE id IN( - SELECT id + SELECT id FROM system_event_log WHERE log_name = ? ORDER BY id DESC @@ -239,75 +267,18 @@ class StatLog { ); } - /* - Find System Log entries by |filter|: - - filter.logName (required) - filter.resultType = (obj) | count - where obj contains timestamp and log_value - filter.limit - filter.date - exact date to filter against - filter.order = (timestamp) | timestamp_asc | timestamp_desc | random - */ + // + // Find System Log entry(s) by |filter|: + // + // - logName: Name of log (required) + // - resultType: 'obj' | 'count' (default='obj') + // - limit: Limit returned results + // - date: exact date to filter against + // - order: 'timestamp' | 'timestamp_asc' | 'timestamp_desc' | 'random' + // (default='timestamp') + // findSystemLogEntries(filter, cb) { - filter = filter || {}; - if (!_.isString(filter.logName)) { - return cb(Errors.MissingParam('filter.logName is required')); - } - - filter.resultType = filter.resultType || 'obj'; - filter.order = filter.order || 'timestamp'; - - let sql; - if ('count' === filter.resultType) { - sql = `SELECT COUNT() AS count - FROM system_event_log`; - } else { - sql = `SELECT timestamp, log_value - FROM system_event_log`; - } - - sql += ' WHERE log_name = ?'; - - if (filter.date) { - filter.date = moment(filter.date); - sql += ` AND DATE(timestamp, "localtime") = DATE("${filter.date.format( - 'YYYY-MM-DD' - )}")`; - } - - if ('count' !== filter.resultType) { - switch (filter.order) { - case 'timestamp': - case 'timestamp_asc': - sql += ' ORDER BY timestamp ASC'; - break; - - case 'timestamp_desc': - sql += ' ORDER BY timestamp DESC'; - break; - - case 'random': - sql += ' ORDER BY RANDOM()'; - break; - } - } - - if (_.isNumber(filter.limit) && 0 !== filter.limit) { - sql += ` LIMIT ${filter.limit}`; - } - - sql += ';'; - - if ('count' === filter.resultType) { - sysDb.get(sql, [filter.logName], (err, row) => { - return cb(err, row ? row.count : 0); - }); - } else { - sysDb.all(sql, [filter.logName], (err, rows) => { - return cb(err, rows); - }); - } + return this._findLogEntries('system_event_log', filter, cb); } getSystemLogEntries(logName, order, limit, cb) { @@ -368,6 +339,211 @@ class StatLog { systemEventUserLogInit(this); return cb(null); } + + // + // Find User Log entry(s) by |filter|: + // + // - logName: Name of log (required) + // - userId: User ID in which to restrict entries to (missing=all) + // - sessionId: Session ID in which to restrict entries to (missing=any) + // - resultType: 'obj' | 'count' (default='obj') + // - limit: Limit returned results + // - date: exact date to filter against + // - order: 'timestamp' | 'timestamp_asc' | 'timestamp_desc' | 'random' + // (default='timestamp') + // + findUserLogEntries(filter, cb) { + return this._findLogEntries('user_event_log', filter, cb); + } + + _refreshSystemStat(statName) { + switch (statName) { + case SysProps.SystemLoadStats: + case SysProps.SystemMemoryStats: + return this._refreshSysInfoStats(); + + case SysProps.ProcessTrafficStats: + return this._refreshProcessTrafficStats(); + } + } + + _refreshSysInfoStats() { + const now = Math.floor(Date.now() / 1000); + if (now < this.lastSysInfoStatsRefresh + 5) { + return; + } + + this.lastSysInfoStatsRefresh = now; + + const basicSysInfo = { + mem: 'total, free', + currentLoad: 'avgLoad, currentLoad', + }; + + SysInfo.get(basicSysInfo) + .then(sysInfo => { + const memStats = { + totalBytes: sysInfo.mem.total, + freeBytes: sysInfo.mem.free, + }; + + this.setNonPersistentSystemStat(SysProps.SystemMemoryStats, memStats); + + const loadStats = { + // Not avail on BSD, yet. + average: parseFloat( + _.get(sysInfo, 'currentLoad.avgLoad', 0).toFixed(2) + ), + current: parseFloat( + _.get(sysInfo, 'currentLoad.currentLoad', 0).toFixed(2) + ), + }; + + this.setNonPersistentSystemStat(SysProps.SystemLoadStats, loadStats); + }) + .catch(err => { + // :TODO: log me + }); + } + + _refreshProcessTrafficStats() { + const trafficStats = getActiveConnections(AllConnections).reduce( + (stats, conn) => { + stats.ingress += conn.rawSocket.bytesRead; + stats.egress += conn.rawSocket.bytesWritten; + return stats; + }, + { ingress: 0, egress: 0 } + ); + + this.setNonPersistentSystemStat(SysProps.ProcessTrafficStats, trafficStats); + } + + _refreshUserStat(client, statName, ttlSeconds) { + switch (statName) { + case UserProps.NewPrivateMailCount: + this._wrapUserRefreshWithCachedTTL( + client, + statName, + this._refreshUserPrivateMailCount, + ttlSeconds + ); + break; + + case UserProps.NewAddressedToMessageCount: + this._wrapUserRefreshWithCachedTTL( + client, + statName, + this._refreshUserNewAddressedToMessageCount, + ttlSeconds + ); + break; + } + } + + _wrapUserRefreshWithCachedTTL(client, statName, updateMethod, ttlSeconds) { + client.statLogRefreshCache = client.statLogRefreshCache || new Map(); + + const now = Math.floor(Date.now() / 1000); + const old = client.statLogRefreshCache.get(statName) || 0; + if (now < old + ttlSeconds) { + return; + } + + updateMethod(client); + client.statLogRefreshCache.set(statName, now); + } + + _refreshUserPrivateMailCount(client) { + const MsgArea = require('./message_area'); + MsgArea.getNewMessageCountInAreaForUser( + client.user.userId, + Message.WellKnownAreaTags.Private, + (err, count) => { + if (!err) { + client.user.setProperty(UserProps.NewPrivateMailCount, count); + } + } + ); + } + + _refreshUserNewAddressedToMessageCount(client) { + const MsgArea = require('./message_area'); + MsgArea.getNewMessageCountAddressedToUser(client, (err, count) => { + if (!err) { + client.user.setProperty(UserProps.NewAddressedToMessageCount, count); + } + }); + } + + _findLogEntries(logTable, filter, cb) { + filter = filter || {}; + if (!_.isString(filter.logName)) { + return cb(Errors.MissingParam('filter.logName is required')); + } + + filter.resultType = filter.resultType || 'obj'; + filter.order = filter.order || 'timestamp'; + + let sql; + if ('count' === filter.resultType) { + sql = `SELECT COUNT() AS count + FROM ${logTable}`; + } else { + sql = `SELECT timestamp, log_value + FROM ${logTable}`; + } + + sql += ' WHERE log_name = ?'; + + if (_.isNumber(filter.userId)) { + sql += ` AND user_id = ${filter.userId}`; + } + + if (filter.sessionId) { + sql += ` AND session_id = ${filter.sessionId}`; + } + + if (filter.date) { + filter.date = moment(filter.date); + sql += ` AND DATE(timestamp, "localtime") = DATE("${filter.date.format( + 'YYYY-MM-DD' + )}")`; + } + + if ('count' !== filter.resultType) { + switch (filter.order) { + case 'timestamp': + case 'timestamp_asc': + sql += ' ORDER BY timestamp ASC'; + break; + + case 'timestamp_desc': + sql += ' ORDER BY timestamp DESC'; + break; + + case 'random': + sql += ' ORDER BY RANDOM()'; + break; + } + } + + if (_.isNumber(filter.limit) && 0 !== filter.limit) { + sql += ` LIMIT ${filter.limit}`; + } + + sql += ';'; + + if ('count' === filter.resultType) { + sysDb.get(sql, [filter.logName], (err, row) => { + return cb(err, row ? row.count : 0); + }); + } else { + sysDb.all(sql, [filter.logName], (err, rows) => { + return cb(err, rows); + }); + } + } } module.exports = new StatLog(); diff --git a/core/string_util.js b/core/string_util.js index fc60f561..3f78b37d 100644 --- a/core/string_util.js +++ b/core/string_util.js @@ -380,7 +380,7 @@ function formatByteSizeAbbr(byteSize) { function formatByteSize(byteSize, withAbbr = false, decimals = 2) { const i = 0 === byteSize ? byteSize : Math.floor(Math.log(byteSize) / Math.log(1024)); - let result = parseFloat((byteSize / Math.pow(1024, i)).toFixed(decimals)); + let result = parseFloat((byteSize / Math.pow(1024, i)).toFixed(decimals)).toString(); if (withAbbr) { result += ` ${BYTE_SIZE_ABBRS[i]}`; } diff --git a/core/sys_event_user_log.js b/core/sys_event_user_log.js index 586bc6a6..1f42ff29 100644 --- a/core/sys_event_user_log.js +++ b/core/sys_event_user_log.js @@ -3,6 +3,7 @@ const Events = require('./events.js'); const LogNames = require('./user_log_name.js'); +const SysProps = require('./system_property.js'); const DefaultKeepForDays = 365; @@ -30,6 +31,7 @@ module.exports = function systemEventUserLogInit(statLog) { const detailHandler = { [systemEvents.NewUser]: e => { append(e, LogNames.NewUser, 1); + statLog.incrementNonPersistentSystemStat(SysProps.NewUsersTodayCount, 1); }, [systemEvents.UserLogin]: e => { append(e, LogNames.Login, 1); diff --git a/core/system_log.js b/core/system_log.js index 21c310ed..1a833f01 100644 --- a/core/system_log.js +++ b/core/system_log.js @@ -6,5 +6,5 @@ // module.exports = { UserAddedRumorz: 'system_rumorz', - UserLoginHistory: 'user_login_history', + UserLoginHistory: 'user_login_history', // JSON object }; diff --git a/core/system_property.js b/core/system_property.js index 239a987a..6d29e013 100644 --- a/core/system_property.js +++ b/core/system_property.js @@ -10,6 +10,7 @@ module.exports = { LoginCount: 'login_count', LoginsToday: 'logins_today', // non-persistent + LastLogin: 'last_login', // object { userId, sessionId, userName, userRealName, timestamp }; non-persistent FileBaseAreaStats: 'file_base_area_stats', // object - see file_base_area.js::getAreaStats FileUlTotalCount: 'ul_total_count', @@ -17,17 +18,27 @@ module.exports = { FileDlTotalCount: 'dl_total_count', FileDlTotalBytes: 'dl_total_bytes', + FileUlTodayCount: 'ul_today_count', // non-persistent + FileUlTodayBytes: 'ul_today_bytes', // non-persistent + FileDlTodayCount: 'dl_today_count', // non-persistent + FileDlTodayBytes: 'dl_today_bytes', // non-persistent + MessageTotalCount: 'message_post_total_count', // total non-private messages on the system; non-persistent MessagesToday: 'message_post_today', // non-private messages posted/imported today; non-persistent - // begin +op non-persistent... - SysOpUsername: 'sysop_username', - SysOpRealName: 'sysop_real_name', - SysOpLocation: 'sysop_location', - SysOpAffiliations: 'sysop_affiliation', - SysOpSex: 'sysop_sex', - SysOpEmailAddress: 'sysop_email_address', - // end +op non-persistent + SysOpUsername: 'sysop_username', // non-persistent + SysOpRealName: 'sysop_real_name', // non-persistent + SysOpLocation: 'sysop_location', // non-persistent + SysOpAffiliations: 'sysop_affiliation', // non-persistent + SysOpSex: 'sysop_sex', // non-persistent + SysOpEmailAddress: 'sysop_email_address', // non-persistent NextRandomRumor: 'random_rumor', + + SystemMemoryStats: 'system_memory_stats', // object { totalBytes, freeBytes }; non-persistent + SystemLoadStats: 'system_load_stats', // object { average, current }; non-persistent + ProcessTrafficStats: 'system_traffic_bytes_ingress', // object { ingress, egress }; non-persistent + + TotalUserCount: 'user_total_count', // non-persistent + NewUsersTodayCount: 'user_new_today_count', // non-persistent }; diff --git a/core/telnet_bridge.js b/core/telnet_bridge.js index f8c5a8d3..b4a836cf 100644 --- a/core/telnet_bridge.js +++ b/core/telnet_bridge.js @@ -223,7 +223,7 @@ exports.getModule = class TelnetBridgeModule extends MenuModule { ); if (err) { - self.client.log.info( + self.client.log.warn( `Telnet bridge connection error: ${err.message}` ); } diff --git a/core/theme.js b/core/theme.js index 81fba949..a08ccd39 100644 --- a/core/theme.js +++ b/core/theme.js @@ -373,6 +373,22 @@ exports.ThemeManager = class ThemeManager { format ); }, + getStatusAvailIndicators: function () { + const format = Config().theme.statusAvailableIndicators || ['Y', 'N']; + return _.get( + theme, + 'customization.defaults.statusAvailableIndicators', + format + ); + }, + getStatusVisibleIndicators: function () { + const format = Config().theme.statusVisibleIndicators || ['Y', 'N']; + return _.get( + theme, + 'customization.defaults.statusVisibleIndicators', + format + ); + }, }; } @@ -380,7 +396,7 @@ exports.ThemeManager = class ThemeManager { async.each([...this.availableThemes.keys()], (themeId, nextThemeId) => { this._loadTheme(themeId, err => { if (!err) { - Log.info({ themeId }, 'Theme reloaded'); + Log.info({ themeId }, `Theme "${themeId}" reloaded`); } return nextThemeId(null); // always proceed }); @@ -635,6 +651,16 @@ function displayThemedPrompt(name, client, options, cb) { ? new ViewController({ client: client }) : options.viewController; + // adjust MCI positions relative to |position| + if (options.position) { + _.forEach(artInfo.mciMap, mci => { + if (mci.position) { + mci.position[0] = options.position.row; + mci.position[1] += options.position.col; + } + }); + } + const loadOpts = { promptName: name, mciMap: artInfo.mciMap, diff --git a/core/user.js b/core/user.js index ec6dcb73..58cf69d9 100644 --- a/core/user.js +++ b/core/user.js @@ -20,10 +20,6 @@ const moment = require('moment'); const sanatizeFilename = require('sanitize-filename'); const ssh2 = require('ssh2'); -exports.isRootUserId = function (id) { - return 1 === id; -}; - module.exports = class User { constructor() { this.userId = 0; @@ -31,6 +27,7 @@ module.exports = class User { this.properties = {}; // name:value this.groups = []; // group membership(s) this.authFactor = User.AuthFactors.None; + this.statusFlags = User.StatusFlags.None; } // static property accessors @@ -73,6 +70,14 @@ module.exports = class User { }; } + static get StatusFlags() { + return { + None: 0x00000000, + NotAvailable: 0x00000001, // Not currently available for chat, message, page, etc. + NotVisible: 0x00000002, // Invisible -- does not show online, last callers, etc. + }; + } + isAuthenticated() { return true === this.authenticated; } @@ -125,6 +130,30 @@ module.exports = class User { return sanatizeFilename(name) || `user${this.userId.toString()}`; } + isAvailable() { + return (this.statusFlags & User.StatusFlags.NotAvailable) == 0; + } + + isVisible() { + return (this.statusFlags & User.StatusFlags.NotVisible) == 0; + } + + setAvailability(available) { + if (available) { + this.statusFlags &= ~User.StatusFlags.NotAvailable; + } else { + this.statusFlags |= User.StatusFlags.NotAvailable; + } + } + + setVisibility(visible) { + if (visible) { + this.statusFlags &= ~User.StatusFlags.NotVisible; + } else { + this.statusFlags |= User.StatusFlags.NotVisible; + } + } + getLegacySecurityLevel() { if (this.isRoot() || this.isGroupMember('sysops')) { return 100; @@ -703,6 +732,47 @@ module.exports = class User { ); } + static getUserInfo(userId, propsList, cb) { + if (!cb && _.isFunction(propsList)) { + cb = propsList; + propsList = [ + UserProps.RealName, + UserProps.Sex, + UserProps.EmailAddress, + UserProps.Location, + UserProps.Affiliations, + ]; + } + + async.waterfall( + [ + callback => { + return User.getUserName(userId, callback); + }, + (userName, callback) => { + User.loadProperties(userId, { names: propsList }, (err, props) => { + return callback( + err, + Object.assign({}, props, { user_name: userName }) + ); + }); + }, + ], + (err, userProps) => { + if (err) { + return cb(err); + } + + const userInfo = {}; + Object.keys(userProps).forEach(key => { + userInfo[_.camelCase(key)] = userProps[key] || 'N/A'; + }); + + return cb(null, userInfo); + } + ); + } + static isRootUserId(userId) { return User.RootUserID === userId; } @@ -835,6 +905,19 @@ module.exports = class User { ); } + static getUserCount(cb) { + userDb.get( + `SELECT count() AS user_count + FROM user;`, + (err, row) => { + if (err) { + return cb(err); + } + return cb(null, row.user_count); + } + ); + } + static getUserList(options, cb) { const userList = []; diff --git a/core/user_config.js b/core/user_config.js index 87c7cebe..859c63e8 100644 --- a/core/user_config.js +++ b/core/user_config.js @@ -139,11 +139,14 @@ exports.getModule = class UserConfigModule extends MenuModule { // :TODO: warn end user! return self.prevMenu(cb); } + + self.client.log.info( + `User "${self.client.user.username}" updated configuration` + ); + // // New password if it's not empty // - self.client.log.info('User updated properties'); - if (formData.value.password.length > 0) { self.client.user.setNewAuthCredentials( formData.value.password, @@ -155,7 +158,7 @@ exports.getModule = class UserConfigModule extends MenuModule { ); } else { self.client.log.info( - 'User changed authentication credentials' + `User "${self.client.user.username}" updated authentication credentials` ); } return self.prevMenu(cb); diff --git a/core/user_interrupt_queue.js b/core/user_interrupt_queue.js index 4bf9a5a0..c495d5a4 100644 --- a/core/user_interrupt_queue.js +++ b/core/user_interrupt_queue.js @@ -26,7 +26,12 @@ module.exports = class UserInterruptQueue { omitNodes = [opts.omit]; } omitNodes = omitNodes.map(n => (_.isNumber(n) ? n : n.node)); - opts.clients = getActiveConnections(true).filter( + const connOpts = { + authUsersOnly: true, + visibleOnly: true, + availOnly: true, + }; + opts.clients = getActiveConnections(connOpts).filter( ac => !omitNodes.includes(ac.node) ); } diff --git a/core/user_login.js b/core/user_login.js index d4a2bf0b..e7242132 100644 --- a/core/user_login.js +++ b/core/user_login.js @@ -24,6 +24,7 @@ const { getFileAreaByTag, getDefaultFileAreaTag } = require('./file_base_area.js const async = require('async'); const _ = require('lodash'); const assert = require('assert'); +const moment = require('moment'); exports.userLogin = userLogin; exports.recordLogin = recordLogin; @@ -40,7 +41,7 @@ function userLogin(client, username, password, options, cb) { if (config.users.badUserNames.includes(username.toLowerCase())) { client.log.info( { username, ip: client.remoteAddress }, - 'Attempt to login with banned username' + `Attempt to login with banned username "${username}"` ); // slow down a bit to thwart brute force attacks @@ -78,13 +79,13 @@ function userLogin(client, username, password, options, cb) { }); if (existingClientConnection) { - client.log.info( + client.log.warn( { existingNodeId: existingClientConnection.node, username: user.username, userId: user.userId, }, - 'Already logged in' + `User "${user.username}" already logged in on node ${existingClientConnection.node}` ); return cb( @@ -102,7 +103,7 @@ function userLogin(client, username, password, options, cb) { username: user.username, }); - client.log.info('Successful login'); + client.log.info(`User "${user.username}" successfully logged in`); // User's unique session identifier is the same as the connection itself user.sessionId = client.session.uniqueId; // convenience @@ -187,6 +188,8 @@ function recordLogin(client, cb) { assert(client.user.authenticated); // don't get in situations where this isn't true const user = client.user; + const loginTimestamp = StatLog.now; + async.parallel( [ callback => { @@ -197,7 +200,7 @@ function recordLogin(client, cb) { return StatLog.setUserStat( user, UserProps.LastLoginTs, - StatLog.now, + loginTimestamp, callback ); }, @@ -219,6 +222,24 @@ function recordLogin(client, cb) { callback ); }, + callback => { + // Update live last login information which includes additional + // (pre-resolved) information such as user name/etc. + const lastLogin = { + userId: user.userId, + sessionId: user.sessionId, + userName: user.username, + realName: user.getProperty(UserProps.RealName), + affiliation: user.getProperty(UserProps.Affiliations), + emailAddress: user.getProperty(UserProps.EmailAddress), + sex: user.getProperty(UserProps.Sex), + location: user.getProperty(UserProps.Location), + timestamp: moment(loginTimestamp), + }; + + StatLog.setNonPersistentSystemStat(SysProps.LastLogin, lastLogin); + return callback(null); + }, ], err => { return cb(err); @@ -234,9 +255,9 @@ function transformLoginError(err, client, username) { err = Errors.BadLogin('To many failed login attempts', ErrorReasons.TooMany); } - client.log.info( + client.log.warn( { username, ip: client.remoteAddress, reason: err.message }, - 'Failed login attempt' + `Failed login attempt for user "${username}", ${client.friendlyRemoteAddress()}` ); return err; } diff --git a/core/user_property.js b/core/user_property.js index 5b39762c..c3f979b3 100644 --- a/core/user_property.js +++ b/core/user_property.js @@ -59,6 +59,8 @@ module.exports = { MinutesOnlineTotalCount: 'minutes_online_total_count', + NewPrivateMailCount: 'new_private_mail_count', // non-persistent + NewAddressedToMessageCount: 'new_addr_to_msg_count', // non-persistent SSHPubKey: 'ssh_public_key', // OpenSSH format (ssh-keygen, etc.) AuthFactor1Types: 'auth_factor1_types', // List of User.AuthFactor1Types value(s) AuthFactor2OTP: 'auth_factor2_otp', // If present, OTP type for 2FA. See OTPTypes diff --git a/core/vertical_menu_view.js b/core/vertical_menu_view.js index 948b1e94..aa196f80 100644 --- a/core/vertical_menu_view.js +++ b/core/vertical_menu_view.js @@ -11,12 +11,14 @@ const pipeToAnsi = require('./color_codes.js').pipeToAnsi; // deps const util = require('util'); const _ = require('lodash'); +const { throws } = require('assert'); exports.VerticalMenuView = VerticalMenuView; function VerticalMenuView(options) { options.cursor = options.cursor || 'hide'; options.justify = options.justify || 'left'; + this.focusItemAtTop = true; MenuView.call(this, options); @@ -48,13 +50,11 @@ function VerticalMenuView(options) { this.updateViewVisibleItems = function () { self.maxVisibleItems = Math.ceil(self.dimens.height / (self.itemSpacing + 1)); + const topIndex = (this.focusItemAtTop ? throws.focusedItemIndex : 0) || 0; + self.viewWindow = { - top: self.focusedItemIndex, - bottom: - Math.min( - self.focusedItemIndex + self.maxVisibleItems, - self.items.length - ) - 1, + top: topIndex, + bottom: Math.min(topIndex + self.maxVisibleItems, self.items.length) - 1, }; }; @@ -100,7 +100,7 @@ function VerticalMenuView(options) { } text = `${sgr}${strUtil.pad( - text, + `${text}${this.styleSGR1}`, this.dimens.width, this.fillChar, this.justify @@ -108,6 +108,18 @@ function VerticalMenuView(options) { self.client.term.write(`${ansi.goto(item.row, self.position.col)}${text}`); this.setRenderCacheItem(index, text, item.focused); }; + + this.drawRemovedItem = function (index) { + if (index <= this.items.length - 1) { + return; + } + const row = this.position.row + index; + this.client.term.rawWrite( + `${ansi.goto(row, this.position.col)}${ansi.normal()}${this.fillChar.repeat( + this.dimens.width + )}` + ); + }; } util.inherits(VerticalMenuView, MenuView); @@ -150,6 +162,11 @@ VerticalMenuView.prototype.redraw = function () { this.drawItem(i); } } + + const remain = Math.max(0, this.dimens.height - this.items.length); + for (let i = this.items.length; i < remain; ++i) { + this.drawRemovedItem(i); + } }; VerticalMenuView.prototype.setHeight = function (height) { @@ -174,15 +191,15 @@ VerticalMenuView.prototype.setFocus = function (focused) { VerticalMenuView.prototype.setFocusItemIndex = function (index) { VerticalMenuView.super_.prototype.setFocusItemIndex.call(this, index); // sets this.focusedItemIndex - const remainAfterFocus = this.items.length - index; + const remainAfterFocus = this.focusItemAtTop + ? this.items.length - index + : this.items.length; if (remainAfterFocus >= this.maxVisibleItems) { + const topIndex = (this.focusItemAtTop ? throws.focusedItemIndex : 0) || 0; + this.viewWindow = { - top: this.focusedItemIndex, - bottom: - Math.min( - this.focusedItemIndex + this.maxVisibleItems, - this.items.length - ) - 1, + top: topIndex, + bottom: Math.min(topIndex + this.maxVisibleItems, this.items.length) - 1, }; this.positionCacheExpired = false; // skip standard behavior @@ -214,6 +231,9 @@ VerticalMenuView.prototype.onKeyPress = function (ch, key) { VerticalMenuView.prototype.getData = function () { const item = this.getItem(this.focusedItemIndex); + if (!item) { + return this.focusedItemIndex; + } return _.isString(item.data) ? item.data : this.focusedItemIndex; }; @@ -392,3 +412,11 @@ VerticalMenuView.prototype.setItemSpacing = function (itemSpacing) { this.positionCacheExpired = true; }; + +VerticalMenuView.prototype.setPropertyValue = function (propName, value) { + if (propName === 'focusItemAtTop' && _.isBoolean(value)) { + this.focusItemAtTop = value; + } + + VerticalMenuView.super_.prototype.setPropertyValue.call(this, propName, value); +}; diff --git a/core/view.js b/core/view.js index d9fbfbf8..5063322a 100644 --- a/core/view.js +++ b/core/view.js @@ -130,7 +130,7 @@ View.prototype.setPosition = function (pos) { // // Allow the following forms: [row, col], { row : r, col : c }, or (row, col) // - if (util.isArray(pos)) { + if (Array.isArray(pos)) { this.position.row = pos[0]; this.position.col = pos[1]; } else if (_.isNumber(pos.row) && _.isNumber(pos.col)) { diff --git a/core/view_controller.js b/core/view_controller.js index 8f00d312..daa37b51 100644 --- a/core/view_controller.js +++ b/core/view_controller.js @@ -281,9 +281,7 @@ function ViewController(options) { const view = self.getView(viewId); if (!view) { - self.client.log.warn({ viewId: viewId }, 'Cannot find view'); - nextItem(null); - return; + return nextItem(null); } const mciConf = config.mci[mci]; @@ -303,14 +301,9 @@ function ViewController(options) { err => { // default to highest ID if no 'submit' entry present if (!submitId) { - var highestIdView = self.getView(highestId); + const highestIdView = self.getView(highestId); if (highestIdView) { highestIdView.submit = true; - } else { - self.client.log.warn( - { highestId: highestId }, - 'View does not exist' - ); } } diff --git a/core/wfc.js b/core/wfc.js new file mode 100644 index 00000000..fd6305b9 --- /dev/null +++ b/core/wfc.js @@ -0,0 +1,626 @@ +// ENiGMA½ +const { MenuModule } = require('./menu_module'); +const stringFormat = require('./string_format'); + +const { + getActiveConnectionList, + AllConnections, + getConnectionByNodeId, + removeClient, +} = require('./client_connections'); +const StatLog = require('./stat_log'); +const SysProps = require('./system_property'); +const UserProps = require('./user_property'); +const Log = require('./logger'); +const Config = require('./config.js').get; +const { Errors } = require('./enig_error'); +const { pipeToAnsi } = require('./color_codes'); +const MultiLineEditTextView = + require('./multi_line_edit_text_view').MultiLineEditTextView; + +// deps +const async = require('async'); +const _ = require('lodash'); +const moment = require('moment'); +const bunyan = require('bunyan'); + +exports.moduleInfo = { + name: 'WFC', + desc: 'Semi-Traditional Waiting For Caller', + author: 'NuSkooler', +}; + +const FormIds = { + main: 0, + help: 1, + fullLog: 2, + confirmKickPrompt: 3, +}; + +const MciViewIds = { + main: { + nodeStatus: 1, + quickLogView: 2, + selectedNodeStatusInfo: 3, + confirmXy: 4, + + customRangeStart: 10, + }, +}; + +// Secure + 2FA + root user + 'wfc' group. +const DefaultACS = 'SCAF2ID1GM[wfc]'; +const MainStatRefreshTimeMs = 5000; // 5s +const MailCountTTLSeconds = 10; + +exports.getModule = class WaitingForCallerModule extends MenuModule { + constructor(options) { + super(options); + + this.config = Object.assign({}, _.get(options, 'menuConfig.config'), { + extraArgs: options.extraArgs, + }); + + // + // Enforce that we have at least a secure connection in our ACS check + // + this.config.acs = this.config.acs; + if (!this.config.acs) { + this.config.acs = DefaultACS; + } else if (!this.config.acs.includes('SC')) { + this.config.acs = 'SC' + this.config.acs; // secure connection at the very least + } + + // ensure the menu instance has this setting + if (!_.has(options, 'menuConfig.config.acs')) { + _.set(options, 'menuConfig.config.acs', this.config.acs); + } + + this.selectedNodeStatusIndex = -1; // no selection + + this.menuMethods = { + toggleAvailable: (formData, extraArgs, cb) => { + const avail = this.client.user.isAvailable(); + this.client.user.setAvailability(!avail); + return this._refreshAll(cb); + }, + toggleVisible: (formData, extraArgs, cb) => { + const visible = this.client.user.isVisible(); + this.client.user.setVisibility(!visible); + return this._refreshAll(cb); + }, + displayHelp: (formData, extraArgs, cb) => { + return this._displayHelpPage(cb); + }, + setNodeStatusSelection: (formData, extraArgs, cb) => { + const nodeStatusView = this.getView('main', MciViewIds.main.nodeStatus); + if (!nodeStatusView) { + return cb(null); + } + + const nodeId = parseInt(formData.ch); // 1-based + if (isNaN(nodeId)) { + return cb(null); + } + + const index = this._getNodeStatusIndexByNodeId(nodeStatusView, nodeId); + if (index > -1) { + this.selectedNodeStatusIndex = index; + this._selectNodeByIndex(nodeStatusView, this.selectedNodeStatusIndex); + + const nodeStatusSelectionView = this.getView( + 'main', + MciViewIds.main.selectedNodeStatusInfo + ); + + if (nodeStatusSelectionView) { + const item = nodeStatusView.getItems()[index]; + this._updateNodeStatusSelection(nodeStatusSelectionView, item); + } + } + + return cb(null); + }, + kickSelectedNode: (formData, extraArgs, cb) => { + return this._confirmKickSelectedNode(cb); + }, + kickNodeYes: (formData, extraArgs, cb) => { + return this._kickSelectedNode(cb); + }, + kickNodeNo: (formData, extraArgs, cb) => { + //this._startRefreshing(); + return cb(null); + }, + }; + } + + initSequence() { + async.series( + [ + callback => { + return this.beforeArt(callback); + }, + callback => { + return this._displayMainPage(false, callback); + }, + ], + () => { + this.finishedLoading(); + } + ); + } + + _displayMainPage(clearScreen, cb) { + async.series( + [ + callback => { + return this.displayArtAndPrepViewController( + 'main', + FormIds.main, + { clearScreen }, + callback + ); + }, + callback => { + const quickLogView = this.getView( + 'main', + MciViewIds.main.quickLogView + ); + if (!quickLogView) { + return callback(null); + } + + if (!this.logRingBuffer) { + const logLevel = + this.config.quickLogLevel || // WFC specific + _.get(Config(), 'logging.rotatingFile.level') || // ...or system setting + 'info'; // ...or default to info + + this.logRingBuffer = new bunyan.RingBuffer({ + limit: quickLogView.dimens.height || 24, + }); + Log.log.addStream({ + name: 'wfc-ringbuffer', + type: 'raw', + level: logLevel, + stream: this.logRingBuffer, + }); + } + + const nodeStatusView = this.getView( + 'main', + MciViewIds.main.nodeStatus + ); + const nodeStatusSelectionView = this.getView( + 'main', + MciViewIds.main.selectedNodeStatusInfo + ); + + if (nodeStatusView && nodeStatusSelectionView) { + nodeStatusView.on('index update', index => { + const item = nodeStatusView.getItems()[index]; + this._updateNodeStatusSelection( + nodeStatusSelectionView, + item + ); + }); + } + + return callback(null); + }, + callback => { + return this._refreshAll(callback); + }, + ], + err => { + if (!err) { + this._startRefreshing(); + } + return cb(err); + } + ); + } + + enter() { + this.client.stopIdleMonitor(); + this._applyOpVisibility(); + super.enter(); + } + + leave() { + _.remove(Log.log.streams, stream => { + return stream.name === 'wfc-ringbuffer'; + }); + + this._restoreOpVisibility(); + + this._stopRefreshing(); + this.client.startIdleMonitor(); + + super.leave(); + } + + _updateNodeStatusSelection(nodeStatusSelectionView, item) { + if (item) { + const nodeStatusSelectionFormat = + this.config.nodeStatusSelectionFormat || '{text}'; + + const s = stringFormat(nodeStatusSelectionFormat, item); + + if (nodeStatusSelectionView instanceof MultiLineEditTextView) { + nodeStatusSelectionView.setAnsi(pipeToAnsi(s, this.client)); + } else { + nodeStatusSelectionView.setText(s); + } + } + } + + _displayHelpPage(cb) { + this._stopRefreshing(); + + this.displayAsset(this.menuConfig.config.art.help, { clearScreen: true }, () => { + this.client.waitForKeyPress(() => { + return this._displayMainPage(true, cb); + }); + }); + } + + _getSelectedNodeItem() { + const nodeStatusView = this.getView('main', MciViewIds.main.nodeStatus); + if (!nodeStatusView) { + return null; + } + + return nodeStatusView.getItem(nodeStatusView.getFocusItemIndex()); + } + + _confirmKickSelectedNode(cb) { + const nodeItem = this._getSelectedNodeItem(); + if (!nodeItem) { + return cb(null); + } + + const confirmView = this.getView('main', MciViewIds.main.confirmXy); + if (!confirmView) { + return cb( + Errors.MissingMci(`Missing prompt XY${MciViewIds.main.confirmXy} MCI`) + ); + } + + // disallow kicking self + if (this.client.node === parseInt(nodeItem.node)) { + return cb(null); + } + + const promptOptions = { + clearAtSubmit: true, + submitNotify: () => { + this._startRefreshing(); + }, + }; + + if (confirmView.dimens.width) { + promptOptions.clearWidth = confirmView.dimens.width; + } + + this._stopRefreshing(); + return this.promptForInput( + { + formName: 'confirmKickPrompt', + formId: FormIds.confirmKickPrompt, + promptName: this.config.confirmKickNodePrompt || 'confirmKickNodePrompt', + prevFormName: 'main', + position: confirmView.position, + }, + promptOptions, + err => { + return cb(err); + } + ); + } + + _kickSelectedNode(cb) { + const nodeItem = this._getSelectedNodeItem(); + if (!nodeItem) { + return cb(Errors.UnexpectedState('Expecting a selected node')); + } + + const client = getConnectionByNodeId(parseInt(nodeItem.node)); + if (!client) { + return cb( + Errors.UnexpectedState(`Expecting a client for node ID ${nodeItem.node}`) + ); + } + + // :TODO: optional kick art + + removeClient(client); + return cb(null); + } + + _applyOpVisibility() { + this.restoreUserIsVisible = this.client.user.isVisible(); + + const vis = this.config.opVisibility || 'current'; + switch (vis) { + case 'hidden': + this.client.user.setVisibility(false); + break; + case 'visible': + this.client.user.setVisibility(true); + break; + default: + break; + } + } + + _restoreOpVisibility() { + this.client.user.setVisibility(this.restoreUserIsVisible); + } + + _startRefreshing() { + if (this.mainRefreshTimer) { + this._stopRefreshing(); + } + + this.mainRefreshTimer = setInterval(() => { + this._refreshAll(); + }, MainStatRefreshTimeMs); + } + + _stopRefreshing() { + if (this.mainRefreshTimer) { + clearInterval(this.mainRefreshTimer); + delete this.mainRefreshTimer; + } + } + + _refreshAll(cb) { + async.series( + [ + callback => { + return this._refreshStats(callback); + }, + callback => { + return this._refreshNodeStatus(callback); + }, + callback => { + return this._refreshQuickLog(callback); + }, + callback => { + this.updateCustomViewTextsWithFilter( + 'main', + MciViewIds.main.customRangeStart, + this.stats + ); + return callback(null); + }, + ], + err => { + if (cb) { + return cb(err); + } + } + ); + } + + _getStatusStrings(isAvailable, isVisible) { + const availIndicators = Array.isArray(this.config.statusAvailableIndicators) + ? this.config.statusAvailableIndicators + : this.client.currentTheme.helpers.getStatusAvailableIndicators(); + const visIndicators = Array.isArray(this.config.statusVisibleIndicators) + ? this.config.statusVisibleIndicators + : this.client.currentTheme.helpers.getStatusVisibleIndicators(); + + return [ + isAvailable ? availIndicators[1] || 'Y' : availIndicators[0] || 'N', + isVisible ? visIndicators[1] || 'Y' : visIndicators[0] || 'N', + ]; + } + + _refreshStats(cb) { + const fileAreaStats = StatLog.getSystemStat(SysProps.FileBaseAreaStats) || {}; + const sysMemStats = StatLog.getSystemStat(SysProps.SystemMemoryStats) || {}; + const sysLoadStats = StatLog.getSystemStat(SysProps.SystemLoadStats) || {}; + const lastLoginStats = StatLog.getSystemStat(SysProps.LastLogin); + const processTrafficStats = + StatLog.getSystemStat(SysProps.ProcessTrafficStats) || {}; + + const now = moment(); + + const [availIndicator, visIndicator] = this._getStatusStrings( + this.client.user.isAvailable(), + this.client.user.isVisible() + ); + + this.stats = { + // Date/Time + nowDate: now.format(this.getDateFormat()), + nowTime: now.format(this.getTimeFormat()), + now: now.format(this._dateTimeFormat('now')), + + // Current process (our Node.js service) + processUptimeSeconds: process.uptime(), + + // Totals + totalCalls: StatLog.getSystemStatNum(SysProps.LoginCount), + totalPosts: StatLog.getSystemStatNum(SysProps.MessageTotalCount), + totalUsers: StatLog.getSystemStatNum(SysProps.TotalUserCount), + totalFiles: fileAreaStats.totalFiles || 0, + totalFileBytes: fileAreaStats.totalFileBytes || 0, + + // Today's Stats + callsToday: StatLog.getSystemStatNum(SysProps.LoginsToday), + postsToday: StatLog.getSystemStatNum(SysProps.MessagesToday), + uploadsToday: StatLog.getSystemStatNum(SysProps.FileUlTodayCount), + uploadBytesToday: StatLog.getSystemStatNum(SysProps.FileUlTodayBytes), + downloadsToday: StatLog.getSystemStatNum(SysProps.FileDlTodayCount), + downloadBytesToday: StatLog.getSystemStatNum(SysProps.FileDlTodayBytes), + newUsersToday: StatLog.getSystemStatNum(SysProps.NewUsersTodayCount), + + // Current + currentUserName: this.client.user.username, + currentUserRealName: + this.client.user.getProperty(UserProps.RealName) || + this.client.user.username, + availIndicator: availIndicator, + visIndicator: visIndicator, + lastLoginUserName: lastLoginStats.userName, + lastLoginRealName: lastLoginStats.realName, + lastLoginDate: moment(lastLoginStats.timestamp).format(this.getDateFormat()), + lastLoginTime: moment(lastLoginStats.timestamp).format(this.getTimeFormat()), + lastLogin: moment(lastLoginStats.timestamp).format( + this._dateTimeFormat('lastLogin') + ), + totalMemoryBytes: sysMemStats.totalBytes || 0, + freeMemoryBytes: sysMemStats.freeBytes || 0, + systemAvgLoad: sysLoadStats.average || 0, + systemCurrentLoad: sysLoadStats.current || 0, + newPrivateMail: StatLog.getUserStatNumByClient( + this.client, + UserProps.NewPrivateMailCount, + MailCountTTLSeconds + ), + newMessagesAddrTo: StatLog.getUserStatNumByClient( + this.client, + UserProps.NewAddressedToMessageCount, + MailCountTTLSeconds + ), + processBytesIngress: processTrafficStats.ingress || 0, + processBytesEgress: processTrafficStats.egress || 0, + }; + + return cb(null); + } + + _getNodeStatusIndexByNodeId(nodeStatusView, nodeId) { + return nodeStatusView.getItems().findIndex(entry => entry.node == nodeId); + } + + _selectNodeByIndex(nodeStatusView, index) { + if (index >= 0 && nodeStatusView.getFocusItemIndex() !== index) { + nodeStatusView.setFocusItemIndex(index); + } else { + nodeStatusView.redraw(); + } + } + + _refreshNodeStatus(cb) { + const nodeStatusView = this.getView('main', MciViewIds.main.nodeStatus); + if (!nodeStatusView) { + return cb(null); + } + + const nodeStatusItems = getActiveConnectionList(AllConnections) + .slice(0, nodeStatusView.dimens.height) + .map(ac => { + // Handle pre-authenticated + if (!ac.authenticated) { + ac.text = ac.userName = '*Pre Auth*'; + ac.action = 'Logging In'; + } + + const [availIndicator, visIndicator] = this._getStatusStrings( + ac.isAvailable, + ac.isVisible + ); + + const timeOn = ac.timeOn || moment.duration(0); + + return Object.assign(ac, { + availIndicator, + visIndicator, + timeOnMinutes: timeOn.asMinutes(), + timeOn: _.upperFirst(timeOn.humanize()), // make friendly + affils: ac.affils || 'N/A', + realName: ac.realName || 'N/A', + }); + }); + + // If this is our first pass, we'll also update the selection + const firstStatusRefresh = nodeStatusView.getCount() === 0; + + // :TODO: Currently this always redraws due to setItems(). We really need painters alg.; The alternative now is to compare items... yuk. + nodeStatusView.setItems(nodeStatusItems); + this._selectNodeByIndex(nodeStatusView, this.selectedNodeStatusIndex); // redraws + + if (firstStatusRefresh) { + const nodeStatusSelectionView = this.getView( + 'main', + MciViewIds.main.selectedNodeStatusInfo + ); + if (nodeStatusSelectionView) { + const item = nodeStatusView.getItems()[0]; + this._updateNodeStatusSelection(nodeStatusSelectionView, item); + } + } + + return cb(null); + } + + _refreshQuickLog(cb) { + const quickLogView = this.viewControllers.main.getView( + MciViewIds.main.quickLogView + ); + if (!quickLogView) { + return cb(null); + } + + const records = this.logRingBuffer.records; + if (records.length === 0) { + return cb(null); + } + + const hasChanged = this.lastLogTime !== records[records.length - 1].time; + this.lastLogTime = records[records.length - 1].time; + + if (!hasChanged) { + return cb(null); + } + + const quickLogTimestampFormat = + this.config.quickLogTimestampFormat || this.getDateTimeFormat('short'); + + const levelIndicators = this.config.quickLogLevelIndicators || { + trace: 'T', + debug: 'D', + info: 'I', + warn: 'W', + error: 'E', + fatal: 'F', + }; + + const makeLevelIndicator = level => { + return levelIndicators[level] || '?'; + }; + + const quickLogLevelMessagePrefixes = + this.config.quickLogLevelMessagePrefixes || {}; + const prefixMssage = (message, level) => { + const prefix = quickLogLevelMessagePrefixes[level] || ''; + return `${prefix}${message}`; + }; + + const logItems = records.map(rec => { + const level = bunyan.nameFromLevel[rec.level]; + return { + timestamp: moment(rec.time).format(quickLogTimestampFormat), + level: rec.level, + levelIndicator: makeLevelIndicator(level), + nodeId: rec.nodeId || '*', + sessionId: rec.sessionId || '', + message: prefixMssage(rec.msg, level), + }; + }); + + quickLogView.setItems(logItems); + quickLogView.redraw(); + + return cb(null); + } + + _dateTimeFormat(element) { + const format = this.config[`${element}DateTimeFormat`]; + return format || this.getDateFormat(); + } +}; diff --git a/core/whos_online.js b/core/whos_online.js index 0ce3a63f..9bc792ae 100644 --- a/core/whos_online.js +++ b/core/whos_online.js @@ -3,7 +3,10 @@ // ENiGMA½ const { MenuModule } = require('./menu_module.js'); -const { getActiveConnectionList } = require('./client_connections.js'); +const { + getActiveConnectionList, + UserVisibleConnections, +} = require('./client_connections.js'); const { Errors } = require('./enig_error.js'); // deps @@ -49,12 +52,14 @@ exports.getModule = class WhosOnlineModule extends MenuModule { ); } - const onlineList = getActiveConnectionList(true) + const onlineList = getActiveConnectionList(UserVisibleConnections) .slice(0, onlineListView.height) .map(oe => Object.assign(oe, { text: oe.userName, - timeOn: _.upperFirst(oe.timeOn.humanize()), + timeOn: oe.timeOn + ? _.upperFirst(oe.timeOn.humanize()) + : 0, // :TODO: fix me. We can always track time... }) ); diff --git a/docs/_docs/admin/updating.md b/docs/_docs/admin/updating.md index ae9412e4..87cf437e 100644 --- a/docs/_docs/admin/updating.md +++ b/docs/_docs/admin/updating.md @@ -23,9 +23,9 @@ npm install # or 'yarn' 5. If there are updates to the `art/themes/luciano_blocktronics/theme.hjson` file and you have a custom theme, you may want to look at them as well. 6. Finally, restart your running ENiGMA½ instance. -:information_source: Visual diff tools such as [DiffMerge](https://www.sourcegear.com/diffmerge/downloads.php) (free, works on all major platforms) can be very helpful for the tasks outlined above! +> :information_source: Visual diff tools such as [DiffMerge](https://www.sourcegear.com/diffmerge/downloads.php) (free, works on all major platforms) can be very helpful for the tasks outlined above! -:bulb: It is recommended to [monitor logs](../troubleshooting/monitoring-logs.md) and poke around a bit after an update! +> :bulb: It is recommended to [monitor logs](../troubleshooting/monitoring-logs.md) and poke around a bit after an update! diff --git a/docs/_docs/art/general.md b/docs/_docs/art/general.md index 551d231e..3ddbf590 100644 --- a/docs/_docs/art/general.md +++ b/docs/_docs/art/general.md @@ -149,12 +149,12 @@ Other "fonts" also available: * `iso8859_1` * `cp1131` -:information_source: See [this specification](https://github.com/protomouse/synchronet/blob/master/src/conio/cterm.txt) for more information. +> :information_source: 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. -:information_source: See [this specification](https://github.com/protomouse/synchronet/blob/master/src/conio/cterm.txt) for more information. +> :information_source: See [this specification](https://github.com/protomouse/synchronet/blob/master/src/conio/cterm.txt) for more information. ### Common Example ```hjson diff --git a/docs/_docs/art/mci.md b/docs/_docs/art/mci.md index d6dc4e9d..75cbb45c 100644 --- a/docs/_docs/art/mci.md +++ b/docs/_docs/art/mci.md @@ -8,9 +8,9 @@ ENiGMA½ supports a variety of MCI codes. Some **predefined** codes produce info ## General Information MCI codes are composed of two characters and are prefixed with a percent (%) symbol. -:information_source: To explicitly tie a MCI to a specific View ID, suffix the MCI code with a number. For example: `%BN1`. +> :information_source: To explicitly tie a MCI to a specific View ID, suffix the MCI code with a number. For example: `%BN1`. -:information_source: Standard (non-focus) and focus colors are set by placing duplicate codes back to back in art files: +> :information_source: Standard (non-focus) and focus colors are set by placing duplicate codes back to back in art files: ![Example](../assets/images/mci-example1.png "MCI Colors") @@ -88,9 +88,29 @@ There are many predefined MCI codes that can be used anywhere on the system (pla | `SU` | Total uploads, system wide | | `SP` | Total uploaded amount, system wide (formatted to appropriate bytes/megs/etc.) | | `TF` | Total number of files on the system | -| `TB` | Total amount of files on the system (formatted to appropriate bytes/megs/gigs/etc.) | +| `TB` | Total file base size (formatted to appropriate bytes/megs/gigs/etc.) | | `TP` | Total messages posted/imported to the system *currently* | | `PT` | Total messages posted/imported to the system *today* | +| `FT` | Total number of uploads to the system *today* | +| `FB` | Total upload amount *today* (formatted to appropriate bytes/megs/etc. ) | +| `DD` | Total number of downloads from the system *today* | +| `DB` | Total download amount *today* (formatted to appropriate bytes/megs/etc. ) | +| `MB` | System memory | +| `MF` | System _free_ memory | +| `LA` | System load average (e.g. 0.25)
(May not be available on some platforms) | +| `CL` | System current load percentage
(May not be available on some platforms) | +| `UU` | System uptime in friendly format | +| `LC` | Last caller to the system (username) | +| `LT` | Time of last caller | +| `LD` | Date of last caller | +| `TU` | Total number of users on the system | +| `NT` | Total *new* users *today* | +| `NM` | Count of new messages **address to the current user** across all message areas in which they have access | +| `NP` | Count of new private mail to the current user | +| `IA` | Indicator as to rather the current user is **available** or not. See also `getStatusAvailIndicators()` in [Themes](themes.md) | +| `IV` | Indicator as to rather the curent user is **visible** or not. See also `getStatusVisibleIndicators()` in [Themes](themes.md) | +| `PI` | Ingress bytes for the current process (since ENiGMA started up) | +| `PE` | Egress bytes for the current process (since ENiGMA started up) | Some additional special case codes also exist: @@ -103,7 +123,7 @@ Some additional special case codes also exist: | `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. | -:information_source: More are added all +> :information_source: More are added all the time so also check out [core/predefined_mci.js](https://github.com/NuSkooler/enigma-bbs/blob/master/core/mci_view_factory.js) for a full listing. @@ -123,13 +143,13 @@ a Vertical Menu (`%VM`): Old-school BBSers may recognize this as a lightbar menu | `BT` | Button | A button | ...it's a button. See [Button](views/button_view.md) | | `VM` | Vertical Menu | A vertical menu | AKA a vertical lightbar; Useful for lists. See [Vertical Menu](views/vertical_menu_view.md) | | `HM` | Horizontal Menu | A horizontal menu | AKA a horizontal lightbar. See [Horizontal Menu](views/horizontal_menu_view.md) | -| `FM` | Full Menu | A menu that can go both vertical and horizontal. | See [Full Menu](views/full_menu_view.md) | +| `FM` | Full Menu | A menu that can go both vertical and horizontal. | See [Full Menu](views/full_menu_view.md) | | `SM` | Spinner Menu | A spinner input control | Select *one* from multiple options. See [Spinner Menu](views/spinner_menu_view.md) | | `TM` | Toggle Menu | A toggle menu | Commonly used for Yes/No style input. See [Toggle Menu](views/toggle_menu_view.md)| | `PL` | Predefined Label | Show environment information | See [Predefined Label](views/predefined_label_view.md)| | `KE` | Key Entry | A *single* key input control | Think hotkeys | -:information_source: Peek at [/core/mci_view_factory.js](https://github.com/NuSkooler/enigma-bbs/blob/master/core/mci_view_factory.js) to see additional information. +> :information_source: Peek at [/core/mci_view_factory.js](https://github.com/NuSkooler/enigma-bbs/blob/master/core/mci_view_factory.js) to see additional information. ### Mask Edits Mask Edits (`%ME`) use the special `maskPattern` property to control a _mask_. This can be useful for gathering dates, phone numbers, so on. @@ -235,4 +255,4 @@ Suppose a format object contains the following elements: `userName` and `affils` ![Example](../assets/images/text-format-example1.png "Text Format") -:bulb: Remember that a Python [string format mini language](https://docs.python.org/3/library/string.html#format-specification-mini-language) style syntax is available for widths, alignment, number prevision, etc. as well. A number can be made to be more human readable for example: `{byteSize:,}` may yield "1,123,456". +> :bulb: Remember that a Python [string format mini language](https://docs.python.org/3/library/string.html#format-specification-mini-language) style syntax is available for widths, alignment, number prevision, etc. as well. A number can be made to be more human readable for example: `{byteSize:,}` may yield "1,123,456". diff --git a/docs/_docs/art/themes.md b/docs/_docs/art/themes.md index f7b234de..ad311566 100644 --- a/docs/_docs/art/themes.md +++ b/docs/_docs/art/themes.md @@ -51,6 +51,8 @@ Override system defaults. | `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. | +| `getStatusAvailIndicators` | An array[2] of **availability** status indicators. Defaults to `[ 'Y', 'N' ]`. | +| `getStatusVisibleIndicators` | An array[2] of **visibility** status indicators. Defaults to `[ 'Y', 'N' ]`. | Example: ```hjson diff --git a/docs/_docs/art/views/button_view.md b/docs/_docs/art/views/button_view.md index 65a753ca..0770ea45 100644 --- a/docs/_docs/art/views/button_view.md +++ b/docs/_docs/art/views/button_view.md @@ -7,9 +7,9 @@ A button view supports displaying a button on a screen. ## General Information -:information_source: A button view is defined with a percent (%) and the characters BT, followed by the view number. For example: `%BT1` +> :information_source: A button view is defined with a percent (%) and the characters BT, followed by the view number. For example: `%BT1` -:information_source: See [MCI](../mci.md) for general information on how to use views and common configuration properties available for them. +> :information_source: See [MCI](../mci.md) for general information on how to use views and common configuration properties available for them. ### Properties @@ -30,11 +30,11 @@ A button view supports displaying a button on a screen. The `textOverflow` option is used to specify what happens when a text string is too long to fit in the `width` defined. -:information_source: If `textOverflow` is not specified at all, a button can become wider than the `width` if needed to display the text value. +> :information_source: If `textOverflow` is not specified at all, a button can become wider than the `width` if needed to display the text value. -:information_source: Setting `textOverflow` to an empty string `textOverflow: ""` will cause the item to be truncated if necessary without any characters displayed +> :information_source: Setting `textOverflow` to an empty string `textOverflow: ""` will cause the item to be truncated if necessary without any characters displayed -:information_source: Otherwise, setting `textOverflow` to one or more characters will truncate the value if necessary and display those characters at the end. i.e. `textOverflow: ...` +> :information_source: Otherwise, setting `textOverflow` to one or more characters will truncate the value if necessary and display those characters at the end. i.e. `textOverflow: ...` ## Example diff --git a/docs/_docs/art/views/edit_text_view.md b/docs/_docs/art/views/edit_text_view.md index c372246d..45e247de 100644 --- a/docs/_docs/art/views/edit_text_view.md +++ b/docs/_docs/art/views/edit_text_view.md @@ -7,9 +7,9 @@ An edit text view supports editing form values on a screen. This can be for new ## General Information -:information_source: An edit text view is defined with a percent (%) and the characters ET, followed by the view number. For example: `%ET1`. This is generally used on a form in order to allow a user to enter or edit a text value. +> :information_source: An edit text view is defined with a percent (%) and the characters ET, followed by the view number. For example: `%ET1`. This is generally used on a form in order to allow a user to enter or edit a text value. -:information_source: See [MCI](../mci.md) for general information on how to use views and common configuration properties available for them. +> :information_source: See [MCI](../mci.md) for general information on how to use views and common configuration properties available for them. ### Properties diff --git a/docs/_docs/art/views/full_menu_view.md b/docs/_docs/art/views/full_menu_view.md index 19ff365a..5e18e1e4 100644 --- a/docs/_docs/art/views/full_menu_view.md +++ b/docs/_docs/art/views/full_menu_view.md @@ -9,9 +9,9 @@ A full menu view supports displaying a list of times on a screen in a very confi Items can be selected on a menu via the cursor keys, Page Up, Page Down, Home, and End, or by selecting them via a `hotKey` - see ***Hot Keys*** below. -:information_source: A full menu view is defined with a percent (%) and the characters FM, followed by the view number. For example: `%FM1` +> :information_source: A full menu view is defined with a percent (%) and the characters FM, followed by the view number. For example: `%FM1` -:information_source: See [MCI](../mci.md) for general information on how to use views and common configuration properties available for them. +> :information_source: See [MCI](../mci.md) for general information on how to use views and common configuration properties available for them. ### Properties @@ -76,11 +76,11 @@ If the list is for display only (there is no form action associated with it) you The `textOverflow` option is used to specify what happens when a text string is too long to fit in the `width` defined. Note, because columns are automatically calculated, this can only occur when the text is too long to fit the `width` using a single column. -:information_source: If `textOverflow` is not specified at all, a menu can become wider than the `width` if needed to display a single column. +> :information_source: If `textOverflow` is not specified at all, a menu can become wider than the `width` if needed to display a single column. -:information_source: Setting `textOverflow` to an empty string `textOverflow: ""` will cause the item to be truncated if necessary without any characters displayed +> :information_source: Setting `textOverflow` to an empty string `textOverflow: ""` will cause the item to be truncated if necessary without any characters displayed -:information_source: Otherwise, setting `textOverflow` to one or more characters will truncate the value if necessary and display those characters at the end. i.e. `textOverflow: ...` +> :information_source: Otherwise, setting `textOverflow` to one or more characters will truncate the value if necessary and display those characters at the end. i.e. `textOverflow: ...` ## Examples diff --git a/docs/_docs/art/views/horizontal_menu_view.md b/docs/_docs/art/views/horizontal_menu_view.md index 90dc4438..d7527632 100644 --- a/docs/_docs/art/views/horizontal_menu_view.md +++ b/docs/_docs/art/views/horizontal_menu_view.md @@ -3,15 +3,15 @@ layout: page title: Horizontal Menu View --- ## Horizontal Menu View -A horizontal menu view supports displaying a list of times on a screen horizontally (side to side, in a single row) similar to a lightbox. +A horizontal menu view supports displaying a list of times on a screen horizontally (side to side, in a single row) similar to a lightbox. ## General Information Items can be selected on a menu via the cursor keys, Page Up, Page Down, Home, and End, or by selecting them via a `hotKey` - see ***Hot Keys*** below. -:information_source: A horizontal menu view is defined with a percent (%) and the characters HM, followed by the view number (if used.) For example: `%HM1` +> :information_source: A horizontal menu view is defined with a percent (%) and the characters HM, followed by the view number (if used.) For example: `%HM1` -:information_source: See [MCI](../mci.md) for general information on how to use views and common configuration properties available for them. +> :information_source: See [MCI](../mci.md) for general information on how to use views and common configuration properties available for them. ### Properties diff --git a/docs/_docs/art/views/mask_edit_text_view.md b/docs/_docs/art/views/mask_edit_text_view.md index a03e83c5..8654d6f5 100644 --- a/docs/_docs/art/views/mask_edit_text_view.md +++ b/docs/_docs/art/views/mask_edit_text_view.md @@ -7,9 +7,9 @@ A mask edit text view supports editing form values on a screen. This can be for ## General Information -:information_source: A mask edit text view is defined with a percent (%) and the characters ME, followed by the view number. For example: `%ME1`. This is generally used on a form in order to allow a user to enter or edit a text value. +> :information_source: A mask edit text view is defined with a percent (%) and the characters ME, followed by the view number. For example: `%ME1`. This is generally used on a form in order to allow a user to enter or edit a text value. -:information_source: See [MCI](../mci.md) for general information on how to use views and common configuration properties available for them. +> :information_source: See [MCI](../mci.md) for general information on how to use views and common configuration properties available for them. ### Properties @@ -45,7 +45,7 @@ Any value other than the entries above is treated like a literal value to be dis | `##-AAA-####` | Matches a date of type day-month-year (i.e. 01-MAR-2010) | | `# foot ## inches`| Matches a height in feet and inches (i.e. 6 foot 2 inches) | - + ## Example ![Example](../../assets/images/mask_edit_text_view_example1.gif "Masked Text Edit View") diff --git a/docs/_docs/art/views/multi_line_edit_text_view.md b/docs/_docs/art/views/multi_line_edit_text_view.md index 870360ba..5882cba7 100644 --- a/docs/_docs/art/views/multi_line_edit_text_view.md +++ b/docs/_docs/art/views/multi_line_edit_text_view.md @@ -7,9 +7,9 @@ A text display / editor designed to edit or display a message. ## General Information -:information_source: A multi line edit text view is defined with a percent (%) and the characters MT, followed by the view number. For example: `%MT1` +> :information_source: A multi line edit text view is defined with a percent (%) and the characters MT, followed by the view number. For example: `%MT1` -:information_source: See [MCI](../mci.md) for general information on how to use views and common configuration properties available for them. +> :information_source: See [MCI](../mci.md) for general information on how to use views and common configuration properties available for them. ### Properties @@ -31,9 +31,9 @@ The mode of a multi line edit text view controls how the view behaves. The follo | preview | preview the text, including scrolling | | read-only | No scrolling or editing the view | -:information_source: If `mode` is not set, the default mode is "edit" +> :information_source: If `mode` is not set, the default mode is "edit" -:information_source: With mode preview, scrolling the contents is allowed, but is not with read-only. +> :information_source: With mode preview, scrolling the contents is allowed, but is not with read-only. ## Example diff --git a/docs/_docs/art/views/predefined_label_view.md b/docs/_docs/art/views/predefined_label_view.md index cae23f55..b9349a23 100644 --- a/docs/_docs/art/views/predefined_label_view.md +++ b/docs/_docs/art/views/predefined_label_view.md @@ -7,11 +7,11 @@ A predefined label view supports displaying a predefined MCI label on a screen. ## General Information -:information_source: A predefined label view is defined with a percent (%) and the characters PL, followed by the view number and then the predefined MCI value in parenthesis. For example: `%PL1(VL)` to display the Version Label. *NOTE*: this is an alternate way of placing MCI codes, as the MCI can also be placed on the art page directly with the code. For example `%VL`. The difference between these is that the PL version can have additional formatting options applied to it. +> :information_source: A predefined label view is defined with a percent (%) and the characters PL, followed by the view number and then the predefined MCI value in parenthesis. For example: `%PL1(VL)` to display the Version Label. *NOTE*: this is an alternate way of placing MCI codes, as the MCI can also be placed on the art page directly with the code. For example `%VL`. The difference between these is that the PL version can have additional formatting options applied to it. -:information_source: See *Predefined Codes* in [MCI](../mci.md) for the list of available MCI codes. +> :information_source: See *Predefined Codes* in [MCI](../mci.md) for the list of available MCI codes. -:information_source: See [MCI](../mci.md) for general information on how to use views and common configuration properties available for them. +> :information_source: See [MCI](../mci.md) for general information on how to use views and common configuration properties available for them. ### Properties @@ -27,11 +27,11 @@ A predefined label view supports displaying a predefined MCI label on a screen. The `textOverflow` option is used to specify what happens when a predefined MCI string is too long to fit in the `width` defined. -:information_source: If `textOverflow` is not specified at all, a predefined label view can become wider than the `width` if needed to display the MCI value. +> :information_source: If `textOverflow` is not specified at all, a predefined label view can become wider than the `width` if needed to display the MCI value. -:information_source: Setting `textOverflow` to an empty string `textOverflow: ""` will cause the item to be truncated if necessary without any characters displayed +> :information_source: Setting `textOverflow` to an empty string `textOverflow: ""` will cause the item to be truncated if necessary without any characters displayed -:information_source: Otherwise, setting `textOverflow` to one or more characters will truncate the value if necessary and display those characters at the end. i.e. `textOverflow: ...` +> :information_source: Otherwise, setting `textOverflow` to one or more characters will truncate the value if necessary and display those characters at the end. i.e. `textOverflow: ...` ## Example diff --git a/docs/_docs/art/views/spinner_menu_view.md b/docs/_docs/art/views/spinner_menu_view.md index 0f7139f8..e2f76624 100644 --- a/docs/_docs/art/views/spinner_menu_view.md +++ b/docs/_docs/art/views/spinner_menu_view.md @@ -9,9 +9,9 @@ A spinner menu view supports displaying a set of times on a screen as a list, wi Items can be selected on a menu via the cursor keys or by selecting them via a `hotKey` - see ***Hot Keys*** below. -:information_source: A spinner menu view is defined with a percent (%) and the characters SM, followed by the view number (if used.) For example: `%SM1` +> :information_source: A spinner menu view is defined with a percent (%) and the characters SM, followed by the view number (if used.) For example: `%SM1` -:information_source: See [MCI](../mci.md) for general information on how to use views and common configuration properties available for them. +> :information_source: See [MCI](../mci.md) for general information on how to use views and common configuration properties available for them. ### Properties diff --git a/docs/_docs/art/views/text_view.md b/docs/_docs/art/views/text_view.md index 3bec8ed8..8eaf9478 100644 --- a/docs/_docs/art/views/text_view.md +++ b/docs/_docs/art/views/text_view.md @@ -7,9 +7,9 @@ A text label view supports displaying simple text on a screen. ## General Information -:information_source: A text label view is defined with a percent (%) and the characters TL, followed by the view number. For example: `%TL1` +> :information_source: A text label view is defined with a percent (%) and the characters TL, followed by the view number. For example: `%TL1` -:information_source: See [MCI](../mci.md) for general information on how to use views and common configuration properties available for them. +> :information_source: See [MCI](../mci.md) for general information on how to use views and common configuration properties available for them. ### Properties @@ -26,11 +26,11 @@ A text label view supports displaying simple text on a screen. The `textOverflow` option is used to specify what happens when a text string is too long to fit in the `width` defined. -:information_source: If `textOverflow` is not specified at all, a text label can become wider than the `width` if needed to display the text value. +> :information_source: If `textOverflow` is not specified at all, a text label can become wider than the `width` if needed to display the text value. -:information_source: Setting `textOverflow` to an empty string `textOverflow: ""` will cause the item to be truncated if necessary without any characters displayed +> :information_source: Setting `textOverflow` to an empty string `textOverflow: ""` will cause the item to be truncated if necessary without any characters displayed -:information_source: Otherwise, setting `textOverflow` to one or more characters will truncate the value if necessary and display those characters at the end. i.e. `textOverflow: ...` +> :information_source: Otherwise, setting `textOverflow` to one or more characters will truncate the value if necessary and display those characters at the end. i.e. `textOverflow: ...` ## Example diff --git a/docs/_docs/art/views/toggle_menu_view.md b/docs/_docs/art/views/toggle_menu_view.md index 65c1eabd..819f431b 100644 --- a/docs/_docs/art/views/toggle_menu_view.md +++ b/docs/_docs/art/views/toggle_menu_view.md @@ -9,9 +9,9 @@ A toggle menu view supports displaying a list of options on a screen horizontall Items can be selected on a menu via the left and right cursor keys, or by selecting them via a `hotKey` - see ***Hot Keys*** below. -:information_source: A toggle menu view is defined with a percent (%) and the characters TM, followed by the view number (if used.) For example: `%TM1` +> :information_source: A toggle menu view is defined with a percent (%) and the characters TM, followed by the view number (if used.) For example: `%TM1` -:information_source: See [MCI](../mci.md) for general information on how to use views and common configuration properties available for them. +> :information_source: See [MCI](../mci.md) for general information on how to use views and common configuration properties available for them. ### Properties diff --git a/docs/_docs/art/views/vertical_menu_view.md b/docs/_docs/art/views/vertical_menu_view.md index e46f92ae..ed439dbc 100644 --- a/docs/_docs/art/views/vertical_menu_view.md +++ b/docs/_docs/art/views/vertical_menu_view.md @@ -9,9 +9,9 @@ A vertical menu view supports displaying a list of times on a screen vertically Items can be selected on a menu via the cursor keys, Page Up, Page Down, Home, and End, or by selecting them via a `hotKey` - see ***Hot Keys*** below. -:information_source: A vertical menu view is defined with a percent (%) and the characters VM, followed by the view number (if used.) For example: `%VM1`. +> :information_source: A vertical menu view is defined with a percent (%) and the characters VM, followed by the view number (if used.) For example: `%VM1`. -:information_source: See [MCI](../mci.md) for general information on how to use views and common configuration properties available for them. +> :information_source: See [MCI](../mci.md) for general information on how to use views and common configuration properties available for them. ### Properties diff --git a/docs/_docs/configuration/archivers.md b/docs/_docs/configuration/archivers.md index 0f55a58b..904d9925 100644 --- a/docs/_docs/configuration/archivers.md +++ b/docs/_docs/configuration/archivers.md @@ -8,7 +8,7 @@ ENiGMA½ can detect and process various archive formats such as zip and arj for Archivers are manged via the `archives:archivers` configuration block of `config.hjson`. Each entry in this section defines an **external archiver** that can be referenced in other sections of `config.hjson` as and in code. Entries define how to `compress`, `decompress` (a full archive), `list`, and `extract` (specific files from an archive). -:bulb: Generally you do not need to anything beyond installing supporting binaries. No `config.hjson` editing necessary; Please see [External Binaries](external-binaries.md)! +> :bulb: Generally you do not need to anything beyond installing supporting binaries. No `config.hjson` editing necessary; Please see [External Binaries](external-binaries.md)! ### Archiver Configuration Archiver entries in `config.hjson` are mostly self explanatory with the exception of `list` commands that require some additional information. The `args` member for an entry is an array of arguments to pass to `cmd`. Some variables are available to `args` that will be expanded by the system: diff --git a/docs/_docs/configuration/config-files.md b/docs/_docs/configuration/config-files.md index dbba9816..3f1e49a6 100644 --- a/docs/_docs/configuration/config-files.md +++ b/docs/_docs/configuration/config-files.md @@ -8,7 +8,7 @@ ENiGMA½ configuration files such as the [system config](config-hjson.md), [menu ## Hot-Reload Nearly all of ENiGMA½'s configuration can be hot-reloaded. That is, a live system can have it's configuration modified and it will be loaded in place. -:bulb: [Monitoring live logs](../troubleshooting/monitoring-logs.md) is useful when making live changes. The system will complain if something is wrong! +> :bulb: [Monitoring live logs](../troubleshooting/monitoring-logs.md) is useful when making live changes. The system will complain if something is wrong! ## Common Directives ### Includes @@ -71,7 +71,7 @@ Consider `actionKeys` in a menu. Often times you may show a screen and the user } ``` -:information_source: An unresolved `@reference` will be left intact. +> :information_source: An unresolved `@reference` will be left intact. ### Environment Variables Especially in a container environment such as [Docker](../installation/docker.md), environment variable access in configuration files can become very handy. ENiGMA½ provides a flexible way to access variables using the `@environment` directive. The most basic form of `@environment:VAR_NAME` produces a string value. Additionally a `:type` suffix can be supplied to coerece the value to a particular type. Variables pointing to a comma separated list can be turned to arrays using an additional `:array` suffix. @@ -97,11 +97,11 @@ Below is a table of the various forms: | `@environment:SOME_VAR:timestamp` | "2020-01-05" | A [moment](https://momentjs.com/) object representing 2020-01-05 | | `@environment:SOME_VAR:timestamp:array` | "2020-01-05,2016-05-16T01:15:37'" | An array of [moment](https://momentjs.com/) objects representing 2020-01-05 and 2016-05-16T01:15:37 | -:bulb: `bool` may be used as an alias to `boolean`. +> :bulb: `bool` may be used as an alias to `boolean`. -:bulb: `timestamp` values can be in any form that [moment can parse](https://momentjs.com/docs/#/parsing/). +> :bulb: `timestamp` values can be in any form that [moment can parse](https://momentjs.com/docs/#/parsing/). -:information_source: An unresolved or invalid `@environment` will be left intact. +> :information_source: An unresolved or invalid `@environment` will be left intact. Consider the following fragment: ```hjson diff --git a/docs/_docs/configuration/config-hjson.md b/docs/_docs/configuration/config-hjson.md index 28d379c5..f92feb07 100644 --- a/docs/_docs/configuration/config-hjson.md +++ b/docs/_docs/configuration/config-hjson.md @@ -7,7 +7,7 @@ The main system configuration file, `config.hjson` both overrides defaults and p The default path is `/enigma-bbs/config/config.hjson` though this can be overridden using the `--config` parameter when invoking `main.js`. -:information_source: See also [Configuration Files](config-files.md). Additionally [HJSON General Information](hjson.md) may be helpful for more information on the HJSON format. +> :information_source: See also [Configuration Files](config-files.md). Additionally [HJSON General Information](hjson.md) may be helpful for more information on the HJSON format. ### Creating a Configuration Your initial configuration skeleton should be created using the `oputil.js` command line utility. From your enigma-bbs root directory: diff --git a/docs/_docs/configuration/event-scheduler.md b/docs/_docs/configuration/event-scheduler.md index 544e082a..accfa7aa 100644 --- a/docs/_docs/configuration/event-scheduler.md +++ b/docs/_docs/configuration/event-scheduler.md @@ -26,7 +26,7 @@ As mentioned above, `schedule` may contain a [Later style](https://bunkat.github An `@watch` clause monitors a specified file for changes and takes the following form: `@watch:` where `` is a fully qualified path. -:bulb: If you would like to have a schedule **and** watch a file for changes, place the `@watch` clause second and separated with the word `or`. For example: `every 24 hours or @watch:/path/to/somefile.txt`. +> :bulb: If you would like to have a schedule **and** watch a file for changes, place the `@watch` clause second and separated 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. diff --git a/docs/_docs/configuration/external-binaries.md b/docs/_docs/configuration/external-binaries.md index 1932a963..656aac3c 100644 --- a/docs/_docs/configuration/external-binaries.md +++ b/docs/_docs/configuration/external-binaries.md @@ -22,9 +22,9 @@ Below is a table of pre-configured archivers. Remember that you can override set | `TarGz` | .tar.gz, .gzip | [Wikipedia](https://en.wikipedia.org/wiki/Gzip) | `tar` | `tar` | [TAR.EXE](https://ss64.com/nt/tar.html) -:information_source: For more information see `core/config_default.js` +> :information_source: For more information see `core/config_default.js` -:information_source: For information on changing configuration or adding more archivers see [Archivers](archivers.md). +> :information_source: For information on changing configuration or adding more archivers see [Archivers](archivers.md). ## File Transfer Protocols Handlers for legacy file transfer protocols such as Z-Modem and Y-Modem. diff --git a/docs/_docs/configuration/hjson.md b/docs/_docs/configuration/hjson.md index 98aef757..a3c52d4e 100644 --- a/docs/_docs/configuration/hjson.md +++ b/docs/_docs/configuration/hjson.md @@ -40,7 +40,7 @@ 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. -:information_source: See also [Configuration Files](../configuration/config-files.md) +> :information_source: See also [Configuration Files](../configuration/config-files.md) ### CaSe SeNsiTiVE Configuration keys are **case sensitive**. That means if a configuration key is `boardName` for example, `boardname`, or `BOARDNAME` **will not work**. diff --git a/docs/_docs/configuration/menu-hjson.md b/docs/_docs/configuration/menu-hjson.md index acf49a52..0912e067 100644 --- a/docs/_docs/configuration/menu-hjson.md +++ b/docs/_docs/configuration/menu-hjson.md @@ -5,9 +5,9 @@ title: Menu HSJON ## Menu HJSON The core of a ENiGMA½ based BBS is it's menus driven by what will be referred to as `menu.hjson`. Throughout ENiGMA½ documentation, when `menu.hjson` is referenced, we're actually talking about `config/menus/yourboardname-*.hjson`. These files determine the menus (or screens) a user can see, the order they come in, how they interact with each other, ACS configuration, and so on. Like all configuration within ENiGMA½, menu configuration is done in [HJSON](https://hjson.org/) format. -:information_source: See also [HJSON General Information](hjson.md) for more information on the HJSON file format. +> :information_source: See also [HJSON General Information](hjson.md) for more information on the HJSON file format. -:bulb: 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: +> :bulb: 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 navigation and menus such as Main, Messages, and Files. * Art file display. @@ -24,7 +24,7 @@ showSomeArt: { ``` As you can see a menu can be very simple. -:information_source: Remember that the top level menu may include additional files using the `includes` directive. See [Configuration Files](config-files.md) for more information on this. +> :information_source: Remember that the top level menu may include additional files using the `includes` directive. See [Configuration Files](config-files.md) for more information on this. ## Common Menu Entry Members 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. Menus that use their own module contain a `module` declaration: diff --git a/docs/_docs/configuration/security.md b/docs/_docs/configuration/security.md index 49870f17..f9a90751 100644 --- a/docs/_docs/configuration/security.md +++ b/docs/_docs/configuration/security.md @@ -15,7 +15,7 @@ Enabling Two-Factor Authentication via One-Time-Password (2FA/OTP) on an account * One or more secure servers enabled such as [SSH](../servers/ssh.md) or secure [WebSockets](../servers/websocket.md) (that is, WebSockets over a secure connection such as TLS). * The [web server](../servers/web-server.md) enabled and exposed over TLS (HTTPS). -:information_source: For WebSockets and the web server, ENiGMA½ _may_ listen on insecure channels if behind a secure web proxy. +> :information_source: For WebSockets and the web server, ENiGMA½ _may_ listen on insecure channels if behind a secure web proxy. ### User Registration Flow Due to the nature of 2FA/OTP, even if enabled on your system, users must opt-in and enable this feature on their account. Users must also have a valid email address such that a registration link can be sent to them. To opt-in, users must enable the option, which will cause the system to email them a registration link. Following the link provides the following: @@ -24,9 +24,9 @@ Due to the nature of 2FA/OTP, even if enabled on your system, users must opt-in 2. If applicable, a scannable QR code for easy device entry (e.g. Google Authenticator) 3. A confirmation prompt in which the user must enter a OTP code. If entered correctly, this validates everything is set up properly and 2FA/OTP will be enabled for the account. Backup codes will also be provided at this time. Future logins will now prompt the user for their OTP after they enter their standard password. -:warning: Serving 2FA/OTP registration links over insecure (HTTP) can expose secrets intended for the user and is **highly** discouraged! +> :warning: Serving 2FA/OTP registration links over insecure (HTTP) can expose secrets intended for the user and is **highly** discouraged! -:memo: +ops can also manually enable or disable 2FA/OTP for a user using [oputil](../admin/oputil.md), but this is generally discouraged. +> :memo: +ops can also manually enable or disable 2FA/OTP for a user using [oputil](../admin/oputil.md), but this is generally discouraged. #### Recovery In the situation that a user loses their 2FA/OTP device (such as a lost phone with Google Auth), there are some options: diff --git a/docs/_docs/filebase/uploads.md b/docs/_docs/filebase/uploads.md index 3d994440..786226e6 100644 --- a/docs/_docs/filebase/uploads.md +++ b/docs/_docs/filebase/uploads.md @@ -19,6 +19,6 @@ uploads: { } ```` -:information_source: Remember that uploads in a particular area are stored **using the first storage tag defined in that area.** +> :information_source: Remember that uploads in a particular area are stored **using the first storage tag defined in that area.** -:bulb: Any ACS checks are allowed. See [ACS](../configuration/acs.md) +> :bulb: Any ACS checks are allowed. See [ACS](../configuration/acs.md) diff --git a/docs/_docs/installation/docker.md b/docs/_docs/installation/docker.md index 1fa1de49..3d7f0c6d 100644 --- a/docs/_docs/installation/docker.md +++ b/docs/_docs/installation/docker.md @@ -40,13 +40,13 @@ if you make any changes to your host config folder they will persist, and you ca ```docker restart ENiGMABBS``` -:bulb: Configuration will be stored in `$(pwd)/enigma-bbs/config`. +> :bulb: Configuration will be stored in `$(pwd)/enigma-bbs/config`. -:bulb: Windows users - you'll need to switch out `$(pwd)/enigma-bbs/config` for a Windows-style path. +> :bulb: Windows users - you'll need to switch out `$(pwd)/enigma-bbs/config` for a Windows-style path. ## Volumes -Containers by their nature are ephermeral. Meaning, stuff you want to keep (config, database, mail) needs +Containers by their nature are ephermeral. Meaning, stuff you want to keep (config, database, mail) needs to be stored outside of the running container. As such, the following volumes are mountable: | Volume | Usage | diff --git a/docs/_docs/installation/install-script.md b/docs/_docs/installation/install-script.md index 809d38db..3923e554 100644 --- a/docs/_docs/installation/install-script.md +++ b/docs/_docs/installation/install-script.md @@ -14,7 +14,7 @@ on GitHub before running it! The script will install `nvm`, Node.js and grab the latest ENiGMA BBS from GitHub. It will also guide you through creating a basic configuration file, and recommend some packages to install. -:information_source: After installing: +> :information_source: After installing: * Read [External Binaries](../configuration/external-binaries.md) * Read [Updating](../admin/updating.md) diff --git a/docs/_docs/installation/manual.md b/docs/_docs/installation/manual.md index eb8101aa..72ae2633 100644 --- a/docs/_docs/installation/manual.md +++ b/docs/_docs/installation/manual.md @@ -24,7 +24,7 @@ Node Version Manager (NVM) is an excellent way to install and manage Node.js ver ```bash curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.33.11/install.sh | bash ``` -:information_source: Do not cut+paste the above command! Visit the [NVM](https://github.com/creationix/nvm) page and run the latest version! +> :information_source: Do not cut+paste the above command! Visit the [NVM](https://github.com/creationix/nvm) page and run the latest version! Next, install Node.js with NVM: ```bash @@ -52,9 +52,9 @@ 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 systems `PATH`. -:information_source: Please see [External Binaries](../configuration/external-binaries.md) for information on setting these up. +> :information_source: Please see [External Binaries](../configuration/external-binaries.md) for information on setting these up. -:information_source: Additional information in [Archivers](../configuration/archivers.md) and [File Transfer Protocols](../configuration/file-transfer-protocols.md) +> :information_source: Additional information in [Archivers](../configuration/archivers.md) and [File Transfer Protocols](../configuration/file-transfer-protocols.md) ## 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 (compliant JSON is also OK). See [Configuration](../configuration/hjson.md) for more information. diff --git a/docs/_docs/messageareas/bso-import-export.md b/docs/_docs/messageareas/bso-import-export.md index bf6ecc03..dcb29e88 100644 --- a/docs/_docs/messageareas/bso-import-export.md +++ b/docs/_docs/messageareas/bso-import-export.md @@ -5,7 +5,7 @@ title: BSO Import / Export ## 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 perform packet transport**! An external [mailer](http://www.filegate.net/bbsmailers.htm) such as [Binkd](https://github.com/pgul/binkd) is required for this task! +> :information_source: ENiGMA½'s `ftn_bso` module is not a mailer and **makes no attempts to perform packet transport**! An external [mailer](http://www.filegate.net/bbsmailers.htm) such as [Binkd](https://github.com/pgul/binkd) is required for this task! ### Configuration Let's look at some of the basic configuration: diff --git a/docs/_docs/messageareas/configuring-a-message-area.md b/docs/_docs/messageareas/configuring-a-message-area.md index b8d3f5a5..2214e437 100644 --- a/docs/_docs/messageareas/configuring-a-message-area.md +++ b/docs/_docs/messageareas/configuring-a-message-area.md @@ -10,7 +10,7 @@ Message Conferences are the top level container for *1:n* Message *Areas* via th Each conference is represented by a entry under `messageConferences`. Each entries top level key is it's *conference tag*. -:bulb: It is **highly** recommended to use snake_case style message *conference tags* and *area tags*! +> :bulb: It is **highly** recommended to use snake_case style message *conference tags* and *area tags*! | Config Item | Required | Description | |-------------|----------|-------------| diff --git a/docs/_docs/messageareas/ftn.md b/docs/_docs/messageareas/ftn.md index 794b76c2..22da103f 100644 --- a/docs/_docs/messageareas/ftn.md +++ b/docs/_docs/messageareas/ftn.md @@ -15,7 +15,7 @@ Getting a fully running FTN enabled system requires a few configuration points: 2. `messageNetworks.ftn.areas`: Establishes local area mappings (ENiGMA½ to/from FTN area tags) and per-area specific configurations. 3. `scannerTossers.ftn_bso`: General configuration for the scanner/tosser (import/export) process. This is also where we configure per-node (uplink) settings. -:information_source: ENiGMA½'s `ftn_bso` module is **not a mailer** and makes **no attempts** to perform packet transport! An external utility such as Binkd is required for this task. +> :information_source: ENiGMA½'s `ftn_bso` module is **not a mailer** and makes **no attempts** to perform packet transport! An external utility such as Binkd is required for this task. #### Networks The `networks` block is a per-network configuration where each entry's ID (or "key") may be referenced elsewhere in `config.hjson`. For example, consider two networks: ArakNet (`araknet`) and fsxNet (`fsxnet`): @@ -70,7 +70,7 @@ Example: } ``` -:bulb: You can import `AREAS.BBS` or FTN style `.NA` files using [oputil](../admin/oputil.md)! +> :bulb: You can import `AREAS.BBS` or FTN style `.NA` files using [oputil](../admin/oputil.md)! #### A More Complete Example Below is a more complete *example* illustrating some of the concepts above: @@ -101,7 +101,7 @@ Below is a more complete *example* illustrating some of the concepts above: } ``` -:information_source: Remember for a complete FTN experience, you'll probably also want to configure [FTN/BSO scanner/tosser](bso-import-export.md) settings. +> :information_source: Remember for a complete FTN experience, you'll probably also want to configure [FTN/BSO scanner/tosser](bso-import-export.md) settings. #### FTN/BSO Scanner Tosser Please see the [FTN/BSO Scanner/Tosser](bso-import-export.md) documentation for information on this area. \ No newline at end of file diff --git a/docs/_docs/messageareas/message-networks.md b/docs/_docs/messageareas/message-networks.md index 52a4227e..a36010f4 100644 --- a/docs/_docs/messageareas/message-networks.md +++ b/docs/_docs/messageareas/message-networks.md @@ -10,7 +10,7 @@ All message network configuration occurs under the `messageNetworks.` bloc 1. `messageNetworks..networks`: Global/general configuration for a particular network where `` is for example `ftn` or `qwk`. 2. `messageNetworks..areas`: Provides mapping of ENiGMA½ **area tags** to their external counterparts. -:information_source: A related section under `scannerTossers.` may provide configuration for scanning (importing) and tossing (exporting) messages for a particular network type. As an example, FidoNet-Style networks often work with BinkleyTerm Style Outbound (BSO) and thus the [FTN/BSO scanner/tosser](bso-import-export.md) (`ftn_bso`) module. +> :information_source: A related section under `scannerTossers.` may provide configuration for scanning (importing) and tossing (exporting) messages for a particular network type. As an example, FidoNet-Style networks often work with BinkleyTerm Style Outbound (BSO) and thus the [FTN/BSO scanner/tosser](bso-import-export.md) (`ftn_bso`) module. ### Currently Supported Networks The following networks are supported out of the box. Remember that you can create modules to add others if desired! diff --git a/docs/_docs/messageareas/qwk.md b/docs/_docs/messageareas/qwk.md index 140180f5..c4b1e177 100644 --- a/docs/_docs/messageareas/qwk.md +++ b/docs/_docs/messageareas/qwk.md @@ -16,7 +16,7 @@ QWK must be considered a semi-standard as there are many implementations. What f ### Configuration QWK configuration occurs in the `messageNetworks.qwk` config block of `config.hjson`. As QWK wants to deal with conference numbers and ENiGMA½ uses area tags (conferences and conference tags are only used for logical grouping), a mapping can be made. -:information_source: During a regular, non QWK-Net exports, conference numbers can be auto-generated. Note that for QWK-Net style networks, you will need to create mappings however. +> :information_source: During a regular, non QWK-Net exports, conference numbers can be auto-generated. Note that for QWK-Net style networks, you will need to create mappings however. Example: ```hjson diff --git a/docs/_docs/modding/local-doors.md b/docs/_docs/modding/local-doors.md index ce948875..3c2404bd 100644 --- a/docs/_docs/modding/local-doors.md +++ b/docs/_docs/modding/local-doors.md @@ -5,7 +5,7 @@ title: Local Doors ## Local Doors ENiGMA½ has many ways to add doors to your system. In addition to the [many built in door server modules](door-servers.md), local doors are of course also supported using the ! The `abracadabra` module! -:information_source: See also [Let’s add a DOS door to Enigma½ BBS](https://medium.com/retro-future/lets-add-a-dos-game-to-enigma-1-2-41f257deaa3c) by Robbie Whiting for a great writeup on adding doors! +> :information_source: See also [Let’s add a DOS door to Enigma½ BBS](https://medium.com/retro-future/lets-add-a-dos-game-to-enigma-1-2-41f257deaa3c) by Robbie Whiting for a great writeup on adding doors! ## The abracadabra Module The `abracadabra` module provides a generic and flexible solution for many door types. Through this module you can execute native processes & scripts directly, and perform I/O through standard I/O (stdio) or a temporary TCP server. diff --git a/docs/_docs/modding/user-2fa-otp-config.md b/docs/_docs/modding/user-2fa-otp-config.md index f2e5f945..b8a3f32a 100644 --- a/docs/_docs/modding/user-2fa-otp-config.md +++ b/docs/_docs/modding/user-2fa-otp-config.md @@ -5,7 +5,7 @@ title: 2FA/OTP Config ## The 2FA/OTP Config Module The `user_2fa_otp_config` module provides opt-in, configuration, and viewing of Two-Factor Authentication via One-Time-Password (2FA/OTP) settings. In order to allow users access to 2FA/OTP, the system must be properly configured. See [Security](../configuration/security.md) for more information. -:information_source: By default, the 2FA/OTP configuration menu may only be accessed by users connected securely (ACS `SC`). It is highly recommended to leave this default as accessing these settings over a plain-text connection could expose private secrets! +> :information_source: By default, the 2FA/OTP configuration menu may only be accessed by users connected securely (ACS `SC`). It is highly recommended to leave this default as accessing these settings over a plain-text connection could expose private secrets! ## Configuration diff --git a/docs/_docs/modding/wfc.md b/docs/_docs/modding/wfc.md new file mode 100644 index 00000000..1aaacfe5 --- /dev/null +++ b/docs/_docs/modding/wfc.md @@ -0,0 +1,106 @@ +--- +layout: page +title: Waiting For Caller (WFC) +--- +## The Waiting For Caller (WFC) Module +The `wfc.js` module provides a Waiting For Caller (WFC) type dashboard from a bygone era. Many traditional features are available including newer concepts for modern times. Node spy is left out as it feels like something that should be left in the past. + +![WFC](../../assets/images/wfc.png)
+ +## Accessing the WFC +By default, the WFC may be accessed via the `!WFC` main menu command when connected over a secure connection via a user with the proper [ACS](../configuration/acs.md). This can be configured as per any other menu in the system. Note that ENiGMA½ does not expose the WFC as a standalone application as this would be much less flexible. To connect locally, simply use your favorite terminal or for example: `ssh -l yourname localhost 8889`. See **Security** below for more information. + +## Security +The system allows any user with the proper security to access the WFC / system operator functionality. The security policy is enforced by ACS with the default of `SCAF2ID1GM[wfc]`, meaning the following are true: + +1. Securely Connected (such as SSH or Secure WebSocket, but not Telnet) +2. [Auth Factor 2+](modding/user-2fa-otp-config.md). That is, the user has 2FA enabled. +3. User ID of 1 (root/admin) +4. The user belongs to the `wfc` group. + +> :information_source: Due to the above, the WFC screen is **disabled** by default as at a minimum, you'll need to add your user to the `wfc` group. See also [Security](../configuration/security.md) for more information on keeping your system secure! + +To change the ACS required, specify a alternative `acs` in the `config` block. For example: +```hjson +mainMenuWaitingForCaller: { + config: { + // initial +op over secure connection only + acs: SCID1GM[sysops] + } +} +``` + +> :lock: ENiGMA½ will enforce ACS of at least `SC` (secure connection) + +## Theming +The following MCI codes are available: +* `VM1`: Node status list with the following format items available: + * `text`: Username or `*Pre Auth*`. + * `action`: Current action/menu. + * `affils`: Any affiliations related to the if `authenticated`, else "N/A". + * `authenticated`: Boolean rather the node is authenticated (logged in) or not. + * `availIndicator`: Indicator of availability (e.g. for messaging)? Displayed via `statusAvailableIndicators` or system theme. See also [Themes](../art/themes.md). + * `isAvailalbe`: Boolean rather the node is availalbe (e.g. for messaging) or not. + * `isSecure`: Is the node securely connected (ie: SSL)? + * `isVisible`: Boolean rather the node is visible to others or not. + * `node`: The node ID. + * `realName`: Real name of authenticated user, or "N/A". + * `serverName`: Name of connected server such as "Telnet" or "SSH". + * `timeOn`: How long the node has been connected. + * `timeOnMinutes`: How long in **minutes** the node has been connected. + * `userId`: User ID of authenticated node, or 0 if not yet authenticated. + * `userName`: User name of authenticated user or "*Pre Auth*" + * `visIndicator`: Indicator of visibility. Displayed via `statusVisibleIndicators` or system theme. See also [Themes](../art/themes.md). + * `remoteAddress`: A friendly formatted remote address such as a IPv4 or IPv6 address. +* `VM2`: Quick log with the following format keys available: + * `timestamp`: Log entry timestamp in `quickLogTimestampFormat` format. + * `level`: Log entry level from Bunyan. + * `levelIndicator`: Level indicators can be overridden with the `quickLogLevelIndicators` key (see defaults below) + * `quickLogLevelIndicators`: A **map** defaulting to the following`: + * `trace` : `T` + * `debug`: `D` + * `info`: `I` + * `warn`: `W` + * `error`: `E` + * `fatal`: `F` + * `nodeId`: Node ID. + * `sessionId`: Session ID. + * `quickLogLevelMessagePrefixes`: A **map** of log level names (see above) to message prefixes. Commonly used for changing message color with pipe codes, such as `|04` for red errors. + * `message`: Log message. +* MCI 10...99: Custom entries with the following format keys available: + * `nowDate`: Current date in the `dateFormat` style, defaulting to `short`. + * `nowTime`: Current time in the `timeFormat` style, defaulting to `short`. + * `now`: Current date and/or time in `nowDateTimeFormat` format. + * `processUptimeSeconds`: Process (the BBS) uptime in seconds. + * `totalCalls`: Total calls to the system. + * `totalPosts`: Total posts to the system. + * `totalUsers`: Total users on the system. + * `totalFiles`: Total number of files on the system. + * `totalFileBytes`: Total size in bytes of the file base. + * `callsToday`: Number of calls today. + * `postsToday`: Number of posts today. + * `uploadsToday`: Number of uploads today. + * `uploadBytesToday`: Total size in bytes of uploads today. + * `downloadsToday`: Number of downloads today. + * `downloadsBytesToday`: Total size in bytes of uploads today. + * `newUsersToday`: Number of new users today. + * `currentUserName`: Current user name. + * `currentUserRealName`: Current user's real name. + * `lastLoginUserName`: Last login username. + * `lastLoginRealName`: Last login user's real name. + * `lastLoginDate`: Last login date in `dateFormat` format. + * `lastLoginTime`: Last login time in `timeFormat` format. + * `lastLogin`: Last login date/time. + * `totalMemoryBytes`: Total system memory in bytes. + * `freeMemoryBytes`: Free system memory in bytes. + * `systemAvgLoad`: System average load. + * `systemCurrentLoad`: System current load. + * `newPrivateMail`: Number of new **private** mail for current user. + * `newMessagesAddrTo`: Number of new messages **addressed to the current user**. + * `availIndicator`: Is the current user availalbe? Displayed via `statusAvailableIndicators` or system theme. See also [Themes](../art/themes.md). + * `visIndicator`: Is the current user visible? Displayed via `statusVisibleIndicators` or system theme. See also [Themes](../art/themes.md). + * `processBytesIngress`: Ingress bytes since ENiGMA started. + * `processBytesEgress`: Egress bytes since ENiGMA started. + + +> :information_source: While [Standard MCI](../art/mci.md) codes work on any menu, they will **not** refresh. For values that may change over time, please use the custom format values above. \ No newline at end of file diff --git a/docs/_docs/modding/whos-online.md b/docs/_docs/modding/whos-online.md index 22fde0ff..744c9d08 100644 --- a/docs/_docs/modding/whos-online.md +++ b/docs/_docs/modding/whos-online.md @@ -8,6 +8,7 @@ The built in `whos_online` module provides a basic who's online mod. ### Theming The following `itemFormat` object is provided to MCI 1 (ie: `%VM1`): * `userId`: User ID. +* `authenticated`: boolean if the client has a logged in user or not. * `userName`: Login username. * `node`: Node ID the user is connected to. * `timeOn`: A human friendly amount of time the user has been online. @@ -15,4 +16,8 @@ The following `itemFormat` object is provided to MCI 1 (ie: `%VM1`): * `location`: User's location. * `affiliation` or `affils`: Users affiliations. * `action`: Current action/view in the system taken from the `desc` field of the current MenuModule they are interacting with. For example, "Playing L.O.R.D". +* `isSecure`: Is the client securely connected? +* `serverName`: Name of connected server such as "Telnet" or "SSH". + +> :information_source: These properties are available via the `client_connections.js` `getActiveConnectionList()` API. diff --git a/docs/_docs/servers/contentservers/gopher.md b/docs/_docs/servers/contentservers/gopher.md index 03c34bed..2bbbcce0 100644 --- a/docs/_docs/servers/contentservers/gopher.md +++ b/docs/_docs/servers/contentservers/gopher.md @@ -29,11 +29,11 @@ ENiGMA will pre-process `gophermap` files replacing in following variables: * `{publicHostname}`: The public hostname from your config. * `{publicPort}`: The public port from your config. -:information_source: See [Wikipedia](https://en.wikipedia.org/wiki/Gopher_(protocol)#Source_code_of_a_menu) for more information on the `gophermap` format. +> :information_source: See [Wikipedia](https://en.wikipedia.org/wiki/Gopher_(protocol)#Source_code_of_a_menu) for more information on the `gophermap` format. -:information_source: See [RFC 1436](https://tools.ietf.org/html/rfc1436) for the original Gopher spec. +> :information_source: See [RFC 1436](https://tools.ietf.org/html/rfc1436) for the original Gopher spec. -:bulb: Tools such as [gfu](https://rawtext.club/~sloum/gfu.html) may help you with `gophermap`'s +> :bulb: Tools such as [gfu](https://rawtext.club/~sloum/gfu.html) may help you with `gophermap`'s ### Example Gophermap An example `gophermap` living in `enigma-bbs/gopher`: diff --git a/docs/_docs/servers/contentservers/web-server.md b/docs/_docs/servers/contentservers/web-server.md index 75c7ea18..2f9fa1cd 100644 --- a/docs/_docs/servers/contentservers/web-server.md +++ b/docs/_docs/servers/contentservers/web-server.md @@ -55,7 +55,7 @@ Entries available under `contentServers.web.https`: If you don't have a TLS certificate for your domain, a good source for a certificate can be [Let's Encrypt](https://letsencrypt.org/) who supplies free and trusted TLS certificates. A common strategy is to place another web server such as [Caddy](https://caddyserver.com/) in front of ENiGMA½ acting as a transparent proxy and TLS termination point. -:information_source: Keep in mind that the SSL certificate provided by Let's Encrypt's Certbot is by default stored in a privileged location; if your ENIGMA instance is not running as root (which it should not be!), you'll need to copy the SSL certificate somewhere else in order for ENIGMA to use it. +> :information_source: Keep in mind that the SSL certificate provided by Let's Encrypt's Certbot is by default stored in a privileged location; if your ENIGMA instance is not running as root (which it should not be!), you'll need to copy the SSL certificate somewhere else in order for ENIGMA to use it. ## Static Routes diff --git a/docs/assets/images/wfc.png b/docs/assets/images/wfc.png new file mode 100644 index 00000000..a900f049 Binary files /dev/null and b/docs/assets/images/wfc.png differ diff --git a/misc/menu_templates/file_base.in.hjson b/misc/menu_templates/file_base.in.hjson index afd497d0..8b102c68 100644 --- a/misc/menu_templates/file_base.in.hjson +++ b/misc/menu_templates/file_base.in.hjson @@ -56,8 +56,8 @@ } fileBaseListEntries: { - module: file_area_list desc: Browsing Files + module: file_area_list config: { art: { browse: FBRWSE @@ -575,8 +575,8 @@ } fileBaseSearch: { - module: file_base_search desc: Searching Files + module: file_base_search art: FSEARCH form: { 0: { @@ -648,8 +648,8 @@ } fileBaseSetNewScanDate: { - module: set_newscan_date desc: File Base + module: set_newscan_date art: SETFNSDATE config: { target: file @@ -679,6 +679,7 @@ } fileBaseExportListFilter: { + desc: File List Export module: file_base_search art: FBLISTEXPSEARCH config: { @@ -754,6 +755,7 @@ } fileBaseExportList: { + desc: File List Export module: file_base_user_list_export art: FBLISTEXP config: { @@ -812,7 +814,7 @@ // default menu entry used by the 'file_base_download_manager' module // for protocol selection fileTransferProtocolSelection: { - desc: Protocol selection + desc: Protocol Selection module: file_transfer_protocol_select art: FPROSEL form: { diff --git a/misc/menu_templates/login.in.hjson b/misc/menu_templates/login.in.hjson index c3fc5340..e708bea5 100644 --- a/misc/menu_templates/login.in.hjson +++ b/misc/menu_templates/login.in.hjson @@ -4,6 +4,7 @@ // Send telnet connections to matrix where users can login, apply, etc. // telnetConnected: { + desc: Telnet Connect art: CONNECT next: matrix config: { nextTimeout: 1500 } @@ -15,6 +16,7 @@ // depending on user ACS. // sshConnected: { + desc: SSH Connect art: CONNECT next: [ { @@ -34,6 +36,7 @@ // application process. // sshConnectedNewUser: { + desc: SSH Connect art: CONNECT next: newUserApplicationPreSsh config: { nextTimeout: 1500 } @@ -41,6 +44,7 @@ // Ye ol' standard matrix matrix: { + desc: Login Matrix art: matrix form: { 0: { @@ -106,6 +110,7 @@ } login: { + desc: Login art: USERLOG next: [ { @@ -156,6 +161,7 @@ } loginAttemptTooNode: { + desc: Already Logged In art: TOONODE config: { cls: true @@ -165,6 +171,7 @@ } loginAttemptAccountLocked: { + desc: Account Locked art: ACCOUNTLOCKED config: { cls: true @@ -174,6 +181,7 @@ } loginAttemptAccountDisabled: { + desc: Account Disabled art: ACCOUNTDISABLED config: { cls: true @@ -183,6 +191,7 @@ } loginAttemptAccountInactive: { + desc: Inactive Account art: ACCOUNTINACTIVE config: { cls: true @@ -192,7 +201,7 @@ } forgotPassword: { - desc: Forgot password + desc: Forgot Password prompt: forgotPasswordPrompt submit: [ { @@ -204,7 +213,7 @@ } forgotPasswordSubmitted: { - desc: Forgot password + desc: Forgot Password art: FORGOTPWSENT config: { cls: true @@ -240,7 +249,7 @@ } fullLoginSequenceOnelinerz: { - desc: Viewing Onelinerz + desc: Onelinerz module: onelinerz next: [ { @@ -350,7 +359,7 @@ } fullLoginSequenceNewScan: { - desc: Performing New Scan + desc: New Scan module: new_scan art: NEWSCAN next: fullLoginSequenceSysStats @@ -360,7 +369,7 @@ } newScanMessageList: { - desc: New Messages + desc: New Message List module: msg_list art: NEWMSGS config: { @@ -403,8 +412,8 @@ } newScanFileBaseList: { - module: file_area_list desc: New Files + module: file_area_list config: { art: { browse: FNEWBRWSE @@ -557,6 +566,7 @@ } loginTwoFactorAuthOTP: { + desc: 2FA art: 2FAOTP next: fullLoginSequenceLoginArt form: { diff --git a/misc/menu_templates/main.in.hjson b/misc/menu_templates/main.in.hjson index f2caef56..208892ab 100644 --- a/misc/menu_templates/main.in.hjson +++ b/misc/menu_templates/main.in.hjson @@ -73,8 +73,8 @@ menus: { mainMenu: { - art: MMENU desc: Main Menu + art: MMENU prompt: menuCommand config: { font: cp437 @@ -153,6 +153,10 @@ value: { command: "MRC" } action: @menu:mrc } + { + value: { command: "!WFC" } + action: @menu:mainMenuWaitingForCaller + } { value: { command: "2FA" } action: [ @@ -193,6 +197,76 @@ } } + mainMenuWaitingForCaller: { + desc: -WFC- + module: wfc + + config: { + art: { + main: wfc + help: wfchelp + } + } + + form: { + 0: { + mci: { + VM1: { + focus: true + } + VM2: { + focus: false + acceptsFocus: false + acceptsInput: false + } + } + + actionKeys: [ + { + keys: [ "a", "shift + a" ] + action: @method:toggleAvailable + } + { + keys: [ "v", "shift + v" ] + action: @method:toggleVisible + } + { + keys: [ "?", "h", "shift + h" ] + action: @method:displayHelp + } + { + keys: [ "1", "2", "3", "4", "5", "6", "7", "8", "9" ] + action: @method:setNodeStatusSelection + } + { + keys: [ "k", "shift + k" ] + action: @method:kickSelectedNode + } + { + keys: [ "escape", "q", "shift + q" ] + action: @systemMethod:prevMenu + } + ] + } + + // confirmKickNodePrompt + 3: { + submit: { + *: [ + { + value: { promptValue: 0 } + action: @method:kickNodeYes + } + { + value: { promptValue: 1 } + action: @method:kickNodeNo + } + ] + } + } + } + } + mrc: { desc: MRC Chat module: mrc @@ -414,6 +488,7 @@ } mainMenuUserConfig: { + desc: User Config module: user_config art: CONFSCR form: { @@ -504,7 +579,7 @@ } mainMenuGlobalNewScan: { - desc: Performing New Scan + desc: New Scan module: new_scan art: NEWSCAN config: { @@ -513,7 +588,7 @@ } mainMenuFeedbackToSysOp: { - desc: Feedback to SysOp + desc: SysOp Feedback module: msg_area_post_fse config: { art: { @@ -802,7 +877,7 @@ } bbsList: { - desc: Viewing BBS List + desc: BBS List module: bbs_list config: { cls: true @@ -920,8 +995,8 @@ } fullLogoffSequencePreAd: { - art: PRELOGAD desc: Logging Off + art: PRELOGAD next: fullLogoffSequenceRandomBoardAd config: { cls: true @@ -930,8 +1005,8 @@ } fullLogoffSequenceRandomBoardAd: { - art: OTHRBBS desc: Logging Off + art: OTHRBBS next: logoff config: { baudRate: 57600 @@ -941,8 +1016,8 @@ } logoff: { - art: LOGOFF desc: Logging Off + art: LOGOFF next: @systemMethod:logoff } @@ -1003,6 +1078,20 @@ ] } + // WFC + confirmKickNodePrompt: { + art: wfckicknodeprompt + mci: { + TM1: { + argName: promptValue + items: [ "yes", "no" ] + focus: true + hotKeys: { Y: 0, N: 1 } + hotKeySubmit: true + } + } + } + //////////////////////////////////////////////////////////////////////// // These entries are required by the system and must exist. // You can still modify/theme them, however. diff --git a/misc/menu_templates/message_base.in.hjson b/misc/menu_templates/message_base.in.hjson index 367e1903..d20d3077 100644 --- a/misc/menu_templates/message_base.in.hjson +++ b/misc/menu_templates/message_base.in.hjson @@ -1,8 +1,8 @@ { menus: { messageBaseMainMenu: { + desc: Message Menu art: MSGMNU - desc: Message Area prompt: messageBaseMenuPrompt config: { interrupt: realtime @@ -68,7 +68,7 @@ } messageBaseNewPost: { - desc: Posting message, + desc: Posting Message module: msg_area_post_fse config: { art: { @@ -184,6 +184,7 @@ } messageBaseChangeCurrentConference: { + desc: Changing Confs art: CCHANGE module: msg_conf_list form: { @@ -209,6 +210,7 @@ } messageBaseChangeCurrentArea: { + desc: Message Area List art: CHANGE module: msg_area_list form: { @@ -234,6 +236,7 @@ } messageBaseMessageList: { + desc: Message List module: msg_list art: MSGLIST config: { @@ -262,8 +265,8 @@ } messageBaseSetNewScanDate: { + desc: New Scan Update module: set_newscan_date - desc: Message Base art: SETMNSDATE config: { target: message @@ -297,7 +300,7 @@ } messageBaseSearch: { - desc: Message Search + desc: Searching Messages module: message_base_search art: MSEARCH config: { @@ -360,7 +363,7 @@ } messageBaseSearchResultsMessageList: { - desc: Message Search + desc: Searching Messages module: msg_list art: MSRCHLST config: { @@ -474,6 +477,7 @@ } messageAreaViewPost: { + desc: Viewing Message module: msg_area_view_fse config: { art: { @@ -599,6 +603,7 @@ } messageAreaReplyPost: { + desc: Replying to Message module: msg_area_post_fse config: { art: { @@ -764,6 +769,7 @@ // conferences using the conference tag as an art spec. // changeMessageConfPreArt: { + desc: Viewing Art module: show_art config: { method: messageConf @@ -781,6 +787,7 @@ // areas using the area tag as an art spec. // changeMessageAreaPreArt: { + desc: Viewing Art module: show_art config: { method: messageArea diff --git a/misc/menu_templates/new_user.in.hjson b/misc/menu_templates/new_user.in.hjson index 084e3788..a574a0d0 100644 --- a/misc/menu_templates/new_user.in.hjson +++ b/misc/menu_templates/new_user.in.hjson @@ -2,9 +2,9 @@ menus: { // A quick preamble - defaults to warning about broken terminals newUserApplicationPre: { + desc: Applying art: NEWUSER1 next: newUserApplication - desc: Applying config: { pause: true cls: true @@ -13,6 +13,7 @@ } newUserApplication: { + desc: Applying module: nua art: NUA next: [ @@ -112,9 +113,9 @@ // A quick preamble - defaults to warning about broken terminals (SSH version) newUserApplicationPreSsh: { + desc: Applying art: NEWUSER1 next: newUserApplicationSsh - desc: Applying config: { pause: true cls: true @@ -127,6 +128,7 @@ // Canceling this form logs off vs falling back to matrix // newUserApplicationSsh: { + desc: Applying module: nua art: NUA fallback: logoff @@ -221,13 +223,14 @@ } newUserFeedbackToSysOpPreamble: { + desc: Applying art: LETTER config: { pause: true } next: newUserFeedbackToSysOp } newUserFeedbackToSysOp: { - desc: Feedback to SysOp + desc: SysOp Feedback module: msg_area_post_fse next: [ { diff --git a/misc/menu_templates/private_mail.in.hjson b/misc/menu_templates/private_mail.in.hjson index ebf4d104..e27314dc 100644 --- a/misc/menu_templates/private_mail.in.hjson +++ b/misc/menu_templates/private_mail.in.hjson @@ -1,8 +1,8 @@ { menus: { privateMailMenu: { - art: MAILMNU desc: Private Mail + art: MAILMNU prompt: menuCommand config: { interrupt: realtime @@ -142,6 +142,7 @@ } privateMailMenuInbox: { + desc: Viewing Inbox module: msg_list art: PRVMSGLIST config: { diff --git a/package.json b/package.json index abb9071c..1e795709 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,8 @@ "uuid": "8.3.2", "uuid-parse": "1.1.0", "ws": "7.4.3", - "yazl": "^2.5.1" + "yazl": "^2.5.1", + "systeminformation": "^5.11.14" }, "devDependencies": { "eslint": "^8.13.0", diff --git a/yarn.lock b/yarn.lock index cd250d1c..a83f55c1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1831,6 +1831,11 @@ supports-color@^7.1.0: dependencies: has-flag "^4.0.0" +systeminformation@^5.11.14: + version "5.11.14" + resolved "https://registry.yarnpkg.com/systeminformation/-/systeminformation-5.11.14.tgz#21fcb6f05d33e17d69c236b9c1b3d9c53d1d2b3a" + integrity sha512-m8CJx3fIhKohanB0ExTk5q53uI1J0g5B09p77kU+KxnxRVpADVqTAwCg1PFelqKsj4LHd+qmVnumb511Hg4xow== + tar@^4: version "4.4.19" resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.19.tgz#2e4d7263df26f2b914dee10c825ab132123742f3"