Merge pull request #431 from NuSkooler/216-waiting-for-caller

#216: Initial Waiting for Caller (WFC) Support
This commit is contained in:
Bryan Ashby 2022-08-04 12:38:14 -06:00 committed by GitHub
commit a6f7fe40c6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
94 changed files with 2071 additions and 493 deletions

View File

@ -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.

View File

@ -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.

View File

@ -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

Binary file not shown.

View File

@ -0,0 +1 @@
>> kick node? %TM1%TM1

View File

@ -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,

View File

@ -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);
},

View File

@ -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);

View File

@ -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
///////////////////////////////////////////////////////////////////////////////

View File

@ -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);
}

View File

@ -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';

View File

@ -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) {

View File

@ -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;
}
},

View File

@ -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 {

View File

@ -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) {

View File

@ -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
);
},

View File

@ -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) {

View File

@ -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);
}
);
}
};

View File

@ -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);
}
);
}
};

View File

@ -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);

View File

@ -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

View File

@ -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)
);
}
};

View File

@ -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) {

View File

@ -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;

View File

@ -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);
});

View File

@ -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})`
);
}

View File

@ -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(),
})

View File

@ -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

View File

@ -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})`
);
}

View File

@ -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();

View File

@ -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]}`;
}

View File

@ -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);

View File

@ -6,5 +6,5 @@
//
module.exports = {
UserAddedRumorz: 'system_rumorz',
UserLoginHistory: 'user_login_history',
UserLoginHistory: 'user_login_history', // JSON object
};

View File

@ -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
};

View File

@ -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}`
);
}

View File

@ -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,

View File

@ -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 = [];

View File

@ -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);

View File

@ -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)
);
}

View File

@ -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;
}

View File

@ -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

View File

@ -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);
};

View File

@ -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)) {

View File

@ -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'
);
}
}

626
core/wfc.js Normal file
View File

@ -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();
}
};

View File

@ -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...
})
);

View File

@ -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!

View File

@ -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

View File

@ -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)<br>(May not be available on some platforms) |
| `CL` | System current load percentage<br>(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".

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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")

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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:

View File

@ -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:<path>` where `<path>` 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.

View File

@ -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.

View File

@ -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**.

View File

@ -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:

View File

@ -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:

View File

@ -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)

View File

@ -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 |

View File

@ -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)

View File

@ -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.

View File

@ -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:

View File

@ -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 |
|-------------|----------|-------------|

View File

@ -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.

View File

@ -10,7 +10,7 @@ All message network configuration occurs under the `messageNetworks.<name>` bloc
1. `messageNetworks.<name>.networks`: Global/general configuration for a particular network where `<name>` is for example `ftn` or `qwk`.
2. `messageNetworks.<name>.areas`: Provides mapping of ENiGMA½ **area tags** to their external counterparts.
:information_source: A related section under `scannerTossers.<name>` 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.<name>` 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!

View File

@ -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

View File

@ -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 [Lets 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 [Lets 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.

View File

@ -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

106
docs/_docs/modding/wfc.md Normal file
View File

@ -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)<br/>
## 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.

View File

@ -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.

View File

@ -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`:

View File

@ -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

BIN
docs/assets/images/wfc.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View File

@ -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: {

View File

@ -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: {

View File

@ -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.

View File

@ -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

View File

@ -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: [
{

View File

@ -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: {

View File

@ -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",

View File

@ -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"