Merge branch '0.0.9-alpha' of github.com:NuSkooler/enigma-bbs into user-interruptions

This commit is contained in:
Bryan Ashby 2018-11-23 22:19:18 -07:00
commit 36e9356663
84 changed files with 1759 additions and 643 deletions

View File

@ -61,6 +61,7 @@ webSocket: {
* The `system.db` `user_event_log` table has been updated to include a unique session ID. Previously this table was not used, but you will need to perform a slight maintenance task before it can be properly used. After updating to `0.0.9-alpha`, please run the following: `sqlite3 db/system.db DROP TABLE user_event_log;`. The new table format will be created and used at startup.
* If you have art configured for message conference or area selection via the `art` configuration value, you will need to include a `show_art` menu reference. Defaulted to `changeMessageConfPreArt` for conferences and `changeMessageAreaPreArt` for areas & included in the example `menu.hjson`.
* Config `defaults` section was theme related and as such, has been renamed to `theme`. `defaults.theme` is now `theme.default`, and `preLoginTheme` is now `theme.preLogin`. See `config.js` if this isn't clear as mud.
* Similar to the last item, `defaults.general.passwordChar` in `theme.hjson` is now just `defaults.passwordChar`.
# 0.0.7-alpha to 0.0.8-alpha

View File

@ -21,7 +21,7 @@ This document attempts to track **major** changes and additions in ENiGMA½. For
* New MCI codes including general purpose movement codes. See [MCI codes](docs/art/mci.md)
* `install.sh` will now attempt to use NPM's `--build-from-source` option when ARM is detected.
* `oputil.js config new` will now generate a much more complete configuration file with comments, examples, etc. `oputil.js config cat` dumps your current config to stdout.
* Handling of failed login attempts is now fully in. Disconnect clients, lock out accounts, ability to auto or unlock at (email-driven) password reset, etc. See `users.failedLogin` in `config.hjson`.
## 0.0.8-alpha

Binary file not shown.

Binary file not shown.

View File

@ -9,9 +9,7 @@
customization: {
defaults: {
general: {
passwordChar: *
}
passwordChar: *
dateTimeFormat: {
short: MMM Do h:mm a
@ -256,6 +254,16 @@
}
}
messageAreaSetNewScanDate: {
mci: {
SM2: {
width: 54
itemFormat: "|00|07{conf.name} |08- |07{area.name}"
focusItemFormat: "|00|15{conf.name} |07- |15{area.name}"
}
}
}
mailMenuCreateMessage: {
0: {
mci: {
@ -800,16 +808,13 @@
}
fileBaseDownloadManager: {
config: {
queueListFormat: "|00|03{fileName:<61.60} {byteSize!sizeWithoutAbbr:>7.6} |11{byteSize!sizeAbbr}"
focusQueueListFormat: "|00|19|15{fileName:<61.60} {byteSize!sizeWithoutAbbr:>7.6} {byteSize!sizeAbbr}"
}
0: {
mci: {
VM1: {
height: 11
width: 69
itemFormat: "|00|03{fileName:<61.60} {byteSize!sizeWithoutAbbr:>7.6} |11{byteSize!sizeAbbr}"
focusItemFormat: "|00|19|15{fileName:<61.60} {byteSize!sizeWithoutAbbr:>7.6} {byteSize!sizeAbbr}"
}
HM2: {
width: 50
@ -821,8 +826,6 @@
fileBaseWebDownloadManager: {
config: {
queueListFormat: "|00|03{webDlLink:<36.35} {fileName:<26.25} {byteSize!sizeWithoutAbbr:>7.6} |11{byteSize!sizeAbbr}"
focusQueueListFormat: "|00|19|15{webDlLink:<36.35} {fileName:<26.25} {byteSize!sizeWithoutAbbr:>7.6} {byteSize!sizeAbbr}"
queueManagerInfoFormat10: "|03batch|08: |03{webBatchDlLink}"
queueManagerInfoFormat11: "|03exp |08: |03{webBatchDlExpire}"
}
@ -831,6 +834,8 @@
mci: {
VM1: {
height: 8
itemFormat: "|00|03{webDlLink:<36.35} {fileName:<26.25} {byteSize!sizeWithoutAbbr:>7.6} |11{byteSize!sizeAbbr}"
focusItemFormat: "|00|19|15{webDlLink:<36.35} {fileName:<26.25} {byteSize!sizeWithoutAbbr:>7.6} {byteSize!sizeAbbr}"
}
HM2: {
width: 50

View File

@ -847,6 +847,8 @@ function peg$parse(input, options) {
const client = options.client;
const user = options.client.user;
const UserProps = require('./user_property.js');
const moment = require('moment');
function checkAccess(acsCode, value) {
@ -863,7 +865,7 @@ function peg$parse(input, options) {
value = [ value ];
}
const userAccountStatus = parseInt(user.properties.account_status, 10);
const userAccountStatus = user.getPropertyAsNumber(UserProps.AccountStatus);
return value.map(n => parseInt(n, 10)).includes(userAccountStatus);
},
EC : function isEncoding() {
@ -888,15 +890,15 @@ function peg$parse(input, options) {
return value.map(n => parseInt(n, 10)).includes(client.node);
},
NP : function numberOfPosts() {
const postCount = parseInt(user.properties.post_count, 10) || 0;
const postCount = user.getPropertyAsNumber(UserProps.PostCount) || 0;
return !isNaN(value) && postCount >= value;
},
NC : function numberOfCalls() {
const loginCount = parseInt(user.properties.login_count, 10);
const loginCount = user.getPropertyAsNumber(UserProps.LoginCount);
return !isNaN(value) && loginCount >= value;
},
AA : function accountAge() {
const accountCreated = moment(user.properties.account_created);
const accountCreated = moment(user.getProperty(UserProps.AccountCreated));
const now = moment();
const daysOld = accountCreated.diff(moment(), 'days');
return !isNaN(value) &&
@ -905,36 +907,36 @@ function peg$parse(input, options) {
daysOld >= value;
},
BU : function bytesUploaded() {
const bytesUp = parseInt(user.properties.ul_total_bytes, 10) || 0;
const bytesUp = user.getPropertyAsNumber(UserProps.FileUlTotalBytes) || 0;
return !isNaN(value) && bytesUp >= value;
},
UP : function uploads() {
const uls = parseInt(user.properties.ul_total_count, 10) || 0;
const uls = user.getPropertyAsNumber(UserProps.FileUlTotalCount) || 0;
return !isNaN(value) && uls >= value;
},
BD : function bytesDownloaded() {
const bytesDown = parseInt(user.properties.dl_total_bytes, 10) || 0;
const bytesDown = user.getPropertyAsNumber(UserProps.FileDlTotalBytes) || 0;
return !isNaN(value) && bytesDown >= value;
},
DL : function downloads() {
const dls = parseInt(user.properties.dl_total_count, 10) || 0;
const dls = user.getPropertyAsNumber(UserProps.FileDlTotalCount) || 0;
return !isNaN(value) && dls >= value;
},
NR : function uploadDownloadRatioGreaterThan() {
const ulCount = parseInt(user.properties.ul_total_count, 10) || 0;
const dlCount = parseInt(user.properties.dl_total_count, 10) || 0;
const ulCount = user.getPropertyAsNumber(UserProps.FileUlTotalCount) || 0;
const dlCount = user.getPropertyAsNumber(UserProps.FileDlTotalCount) || 0;
const ratio = ~~((ulCount / dlCount) * 100);
return !isNaN(value) && ratio >= value;
},
KR : function uploadDownloadByteRatioGreaterThan() {
const ulBytes = parseInt(user.properties.ul_total_bytes, 10) || 0;
const dlBytes = parseInt(user.properties.dl_total_bytes, 10) || 0;
const ulBytes = user.getPropertyAsNumber(UserProps.FileUlTotalBytes) || 0;
const dlBytes = user.getPropertyAsNumber(UserProps.FileDlTotalBytes) || 0;
const ratio = ~~((ulBytes / dlBytes) * 100);
return !isNaN(value) && ratio >= value;
},
PC : function postCallRatio() {
const postCount = parseInt(user.properties.post_count, 10) || 0;
const loginCount = parseInt(user.properties.login_count, 10);
const postCount = user.getPropertyAsNumber(UserProps.PostCount) || 0;
const loginCount = user.getPropertyAsNumber(UserProps.LoginCount) || 0;
const ratio = ~~((postCount / loginCount) * 100);
return !isNaN(value) && ratio >= value;
},

View File

@ -10,6 +10,7 @@ const conf = require('./config.js');
const logger = require('./logger.js');
const database = require('./database.js');
const resolvePath = require('./misc_util.js').resolvePath;
const UserProps = require('./user_property.js');
// deps
const async = require('async');
@ -229,18 +230,21 @@ function initialize(cb) {
},
function getOpProps(opUserName, next) {
const propLoadOpts = {
names : [ 'real_name', 'sex', 'email_address', 'location', 'affiliation' ],
names : [
UserProps.RealName, UserProps.Sex, UserProps.EmailAddress,
UserProps.Location, UserProps.Affiliations,
],
};
User.loadProperties(User.RootUserID, propLoadOpts, (err, opProps) => {
return next(err, opUserName, opProps);
return next(err, opUserName, opProps, propLoadOpts);
});
}
],
(err, opUserName, opProps) => {
(err, opUserName, opProps, propLoadOpts) => {
const StatLog = require('./stat_log.js');
if(err) {
[ 'username', 'real_name', 'sex', 'email_address', 'location', 'affiliation' ].forEach(v => {
propLoadOpts.concat('username').forEach(v => {
StatLog.setNonPeristentSystemStat(`sysop_${v}`, 'N/A');
});
} else {

View File

@ -2,13 +2,14 @@
'use strict';
// ENiGMA½
const logger = require('./logger.js');
const Events = require('./events.js');
const logger = require('./logger.js');
const Events = require('./events.js');
const UserProps = require('./user_property.js');
// deps
const _ = require('lodash');
const moment = require('moment');
const hashids = require('hashids');
const _ = require('lodash');
const moment = require('moment');
const hashids = require('hashids');
exports.getActiveConnections = getActiveConnections;
exports.getActiveConnectionList = getActiveConnectionList;
@ -47,11 +48,11 @@ function getActiveConnectionList(authUsersOnly) {
//
if(ac.user.isAuthenticated()) {
entry.userName = ac.user.username;
entry.realName = ac.user.properties.real_name;
entry.location = ac.user.properties.location;
entry.affils = entry.affiliation = ac.user.properties.affiliation;
entry.realName = ac.user.properties[UserProps.RealName];
entry.location = ac.user.properties[UserProps.Location];
entry.affils = entry.affiliation = ac.user.properties[UserProps.Affiliations];
const diff = now.diff(moment(ac.user.properties.last_login_timestamp), 'minutes');
const diff = now.diff(moment(ac.user.properties[UserProps.LastLoginTs]), 'minutes');
entry.timeOn = moment.duration(diff, 'minutes');
}
return entry;
@ -62,7 +63,7 @@ function addNewClient(client, clientSock) {
const id = client.session.id = clientConnections.push(client) - 1;
const remoteAddress = client.remoteAddress = clientSock.remoteAddress;
// create a uniqe identifier one-time ID for this session
// create a unique identifier one-time ID for this session
client.session.uniqueId = new hashids('ENiGMA½ClientSession').encode([ id, moment().valueOf() ]);
// Create a client specific logger

View File

@ -110,7 +110,8 @@ function renegadeToAnsi(s, client) {
result += s.substr(lastIndex, m.index - lastIndex) + attr;
} else if(m[4] || m[1]) {
// |AA MCI code or |Cx## movement where ## is in m[1]
const val = getPredefinedMCIValue(client, m[4] || m[1], m[2]) || (m[0]); // value itself or literal
let val = getPredefinedMCIValue(client, m[4] || m[1], m[2]);
val = _.isString(val) ? val : m[0]; // value itself or literal
result += s.substr(lastIndex, m.index - lastIndex) + val;
} else if(m[5]) {
// || -- literal '|', that is.

View File

@ -42,17 +42,53 @@ function hasMessageConferenceAndArea(config) {
return result;
}
const ArrayReplaceKeyPaths = [
'loginServers.ssh.algorithms.kex',
'loginServers.ssh.algorithms.cipher',
'loginServers.ssh.algorithms.hmac',
'loginServers.ssh.algorithms.compress',
];
const ArrayReplaceKeys = [
'args',
'sendArgs', 'recvArgs', 'recvArgsNonBatch',
];
function mergeValidateAndFinalize(config, cb) {
const defaultConfig = getDefaultConfig();
const arrayReplaceKeyPathsMutable = _.clone(ArrayReplaceKeyPaths);
const shouldReplaceArray = (arr, key) => {
if(ArrayReplaceKeys.includes(key)) {
return true;
}
for(let i = 0; i < arrayReplaceKeyPathsMutable.length; ++i) {
const o = _.get(defaultConfig, arrayReplaceKeyPathsMutable[i]);
if(_.isEqual(o, arr)) {
arrayReplaceKeyPathsMutable.splice(i, 1);
return true;
}
}
return false;
};
async.waterfall(
[
function mergeWithDefaultConfig(callback) {
const mergedConfig = _.mergeWith(
getDefaultConfig(),
config, (conf1, conf2) => {
// Arrays should always concat
if(_.isArray(conf1)) {
// :TODO: look for collisions & override dupes
return conf1.concat(conf2);
defaultConfig,
config,
(defConfig, userConfig, key) => {
if(Array.isArray(defConfig) && Array.isArray(userConfig)) {
//
// Arrays are special: Some we merge, while others
// we simply replace.
//
if(shouldReplaceArray(defConfig, key)) {
return userConfig;
} else {
return _.uniq(defConfig.concat(userConfig));
}
}
}
);
@ -136,7 +172,6 @@ function getDefaultConfig() {
// :TODO: closedSystem and loginAttemps prob belong under users{}?
closedSystem : false, // is the system closed to new users?
loginAttempts : 3,
menuFile : 'menu.hjson', // 'oputil.js config new' will set this appropriately in config.hjson; may be full path
promptFile : 'prompt.hjson', // 'oputil.js config new' will set this appropriately in config.hjson; may be full path
@ -181,6 +216,13 @@ function getDefaultConfig() {
preAuthIdleLogoutSeconds : 60 * 3, // 3m
idleLogoutSeconds : 60 * 6, // 6m
failedLogin : {
disconnect : 3, // 0=disabled
lockAccount : 9, // 0=disabled; Mark user status as "locked" if >= N
autoUnlockMinutes : 60 * 6, // 0=disabled; Auto unlock after N minutes.
},
unlockAtEmailPwReset : true, // if true, password reset via email will unlock locked accounts
},
theme : {

View File

@ -67,7 +67,13 @@ function loadDatabaseForMod(modInfo, cb) {
function getISOTimestampString(ts) {
ts = ts || moment();
return ts.format('YYYY-MM-DDTHH:mm:ss.SSSZ');
if(!moment.isMoment(ts)) {
if(_.isString(ts)) {
ts = ts.replace(/\//g, '-');
}
ts = moment(ts);
}
return ts.utc().format('YYYY-MM-DDTHH:mm:ss.SSS[Z]');
}
function sanatizeString(s) {

View File

@ -2,6 +2,7 @@
'use strict';
const FileEntry = require('./file_entry.js');
const UserProps = require('./user_property.js');
// deps
const { partition } = require('lodash');
@ -11,8 +12,8 @@ module.exports = class DownloadQueue {
this.client = client;
if(!Array.isArray(this.client.user.downloadQueue)) {
if(this.client.user.properties.dl_queue) {
this.loadFromProperty(this.client.user.properties.dl_queue);
if(this.client.user.properties[UserProps.DownloadQueue]) {
this.loadFromProperty(this.client.user.properties[UserProps.DownloadQueue]);
} else {
this.client.user.downloadQueue = [];
}

View File

@ -4,6 +4,7 @@
// ENiGMA½
const Config = require('./config.js').get;
const StatLog = require('./stat_log.js');
const UserProps = require('./user_property.js');
// deps
const fs = require('graceful-fs');
@ -84,6 +85,8 @@ module.exports = class DropFile {
const prop = this.client.user.properties;
const now = moment();
const secLevel = this.client.user.getLegacySecurityLevel().toString();
const fullName = prop[UserProps.RealName] || this.client.user.username;
const bd = moment(prop[UserProp.Birthdate).format('MM/DD/YY');
// :TODO: fix time remaining
// :TODO: fix default protocol -- user prop: transfer_protocol
@ -97,13 +100,13 @@ module.exports = class DropFile {
'Y', // "Printer Toggle - Y=On N=Off (Default to Y)"
'Y', // "Page Bell - Y=On N=Off (Default to Y)"
'Y', // "Caller Alarm - Y=On N=Off (Default to Y)"
prop.real_name || this.client.user.username, // "User Full Name"
prop.location || 'Anywhere', // "Calling From"
fullName, // "User Full Name"
prop[UserProps.Location]|| 'Anywhere', // "Calling From"
'123-456-7890', // "Home Phone"
'123-456-7890', // "Work/Data Phone"
'NOPE', // "Password" (Note: this is never given out or even stored plaintext)
secLevel, // "Security Level"
prop.login_count.toString(), // "Total Times On"
prop[UserProps.LoginCount].toString(), // "Total Times On"
now.format('MM/DD/YY'), // "Last Date Called"
'15360', // "Seconds Remaining THIS call (for those that particular)"
'256', // "Minutes Remaining THIS call"
@ -120,7 +123,7 @@ module.exports = class DropFile {
'0', // "Total Downloads"
'0', // "Daily Download "K" Total"
'999999', // "Daily Download Max. "K" Limit"
moment(prop.birthdate).format('MM/DD/YY'), // "Caller's Birthdate"
bd, // "Caller's Birthdate"
'X:\\MAIN\\', // "Path to the MAIN directory (where User File is)"
'X:\\GEN\\', // "Path to the GEN directory"
StatLog.getSystemStat('sysop_username'), // "Sysop's Name (name BBS refers to Sysop as)"
@ -141,7 +144,7 @@ module.exports = class DropFile {
'0', // "Files d/led so far today"
'0', // "Total "K" Bytes Uploaded"
'0', // "Total "K" Bytes Downloaded"
prop.user_comment || 'None', // "User Comment"
prop[UserProps.UserComment] || 'None', // "User Comment"
'0', // "Total Doors Opened"
'0', // "Total Messages Left"
@ -168,7 +171,7 @@ module.exports = class DropFile {
'115200',
Config().general.boardName,
this.client.user.userId.toString(),
this.client.user.properties.real_name || this.client.user.username,
this.client.user.properties[UserProps.RealName] || this.client.user.username,
this.client.user.username,
this.client.user.getLegacySecurityLevel().toString(),
'546', // :TODO: Minutes left!
@ -189,21 +192,22 @@ module.exports = class DropFile {
const opUserName = /[^\s]*/.exec(StatLog.getSystemStat('sysop_username'))[0];
const userName = /[^\s]*/.exec(this.client.user.username)[0];
const secLevel = this.client.user.getLegacySecurityLevel().toString();
const location = this.client.user.properties[UserProps.Location];
return iconv.encode( [
Config().general.boardName, // "The name of the system."
opUserName, // "The sysop's name up to the first space."
opUserName, // "The sysop's name following the first space."
'COM1', // "The serial port the modem is connected to, or 0 if logged in on console."
'57600', // "The current port (DTE) rate."
'0', // "The number "0""
userName, // "The current user's name, up to the first space."
userName, // "The current user's name, following the first space."
this.client.user.properties.location || '', // "Where the user lives, or a blank line if unknown."
'1', // "The number "0" if TTY, or "1" if ANSI."
secLevel, // "The number 5 for problem users, 30 for regular users, 80 for Aides, and 100 for Sysops."
'546', // "The number of minutes left in the current user's account, limited to 546 to keep from overflowing other software."
'-1' // "The number "-1" if using an external serial driver or "0" if using internal serial routines."
Config().general.boardName, // "The name of the system."
opUserName, // "The sysop's name up to the first space."
opUserName, // "The sysop's name following the first space."
'COM1', // "The serial port the modem is connected to, or 0 if logged in on console."
'57600', // "The current port (DTE) rate."
'0', // "The number "0""
userName, // "The current user's name, up to the first space."
userName, // "The current user's name, following the first space."
location || '', // "Where the user lives, or a blank line if unknown."
'1', // "The number "0" if TTY, or "1" if ANSI."
secLevel, // "The number 5 for problem users, 30 for regular users, 80 for Aides, and 100 for Sysops."
'546', // "The number of minutes left in the current user's account, limited to 546 to keep from overflowing other software."
'-1' // "The number "-1" if using an external serial driver or "0" if using internal serial routines."
].join('\r\n') + '\r\n', 'cp437');
}

View File

@ -34,8 +34,9 @@ exports.Errors = {
ExternalProcess : (reason, reasonCode) => new EnigError('External process error', -32005, reason, reasonCode),
MissingConfig : (reason, reasonCode) => new EnigError('Missing configuration', -32006, reason, reasonCode),
UnexpectedState : (reason, reasonCode) => new EnigError('Unexpected state', -32007, reason, reasonCode),
MissingParam : (reason, reasonCode) => new EnigError('Missing paramater(s)', -32008, reason, reasonCode),
MissingParam : (reason, reasonCode) => new EnigError('Missing paramter(s)', -32008, reason, reasonCode),
MissingMci : (reason, reasonCode) => new EnigError('Missing required MCI code(s)', -32009, reason, reasonCode),
BadLogin : (reason, reasonCode) => new EnigError('Bad login attempt', -32010, reason, reasonCode),
};
exports.ErrorReasons = {
@ -44,4 +45,9 @@ exports.ErrorReasons = {
NoPreviousMenu : 'NOPREV',
NoConditionMatch : 'NOCONDMATCH',
NotEnabled : 'NOTENABLED',
};
AlreadyLoggedIn : 'ALREADYLOGGEDIN',
TooMany : 'TOOMANY',
Disabled : 'DISABLED',
Inactive : 'INACTIVE',
Locked : 'LOCKED',
};

View File

@ -5,6 +5,7 @@
const PluginModule = require('./plugin_module.js').PluginModule;
const Config = require('./config.js').get;
const Log = require('./logger.js').log;
const { Errors } = require('./enig_error.js');
const _ = require('lodash');
const later = require('later');
@ -116,7 +117,7 @@ class ScheduledEvent {
methodModule[this.action.what](this.action.args, err => {
if(err) {
Log.debug(
{ error : err.toString(), eventName : this.name, action : this.action },
{ error : err.message, eventName : this.name, action : this.action },
'Error performing scheduled event action');
}
@ -124,7 +125,7 @@ class ScheduledEvent {
});
} catch(e) {
Log.warn(
{ error : e.toString(), eventName : this.name, action : this.action },
{ error : e.message, eventName : this.name, action : this.action },
'Failed to perform scheduled event action');
return cb(e);
@ -138,7 +139,22 @@ class ScheduledEvent {
env : process.env,
};
const proc = pty.spawn(this.action.what, this.action.args, opts);
let proc;
try {
proc = pty.spawn(this.action.what, this.action.args, opts);
} catch(e) {
Log.warn(
{
error : 'Failed to spawn @execute process',
reason : e.message,
eventName : this.name,
action : this.action,
what : this.action.what,
args : this.action.args
}
);
return cb(e);
}
proc.once('exit', exitCode => {
if(exitCode) {

View File

@ -7,6 +7,7 @@ const ViewController = require('./view_controller.js').ViewContro
const getSortedAvailableFileAreas = require('./file_base_area.js').getSortedAvailableFileAreas;
const FileBaseFilters = require('./file_base_filter.js');
const stringFormat = require('./string_format.js');
const UserProps = require('./user_property.js');
// deps
const async = require('async');
@ -111,7 +112,7 @@ exports.getModule = class FileAreaFilterEdit extends MenuModule {
//
// If the item was also the active filter, we need to make a new one active
//
if(filterUuid === this.client.user.properties.file_base_filter_active_uuid) {
if(filterUuid === this.client.user.properties[UserProps.FileBaseFilterActiveUuid]) {
const newActive = this.filtersArray[this.currentFilterIndex];
if(newActive) {
filters.setActive(newActive.uuid);

View File

@ -14,6 +14,7 @@ const resolveMimeType = require('./mime_util.js').resolveMimeType;
const stringFormat = require('./string_format.js');
const wordWrapText = require('./word_wrap.js').wordWrapText;
const StatLog = require('./stat_log.js');
const UserProps = require('./user_property.js');
// deps
const _ = require('lodash');
@ -136,11 +137,11 @@ function changeFileAreaWithOptions(client, areaTag, options, cb) {
},
function changeArea(area, callback) {
if(true === options.persist) {
client.user.persistProperty('file_area_tag', areaTag, err => {
client.user.persistProperty(UserProps.FileAreaTag, areaTag, err => {
return callback(err, area);
});
} else {
client.user.properties['file_area_tag'] = areaTag;
client.user.properties[UserProps.FileAreaTag] = areaTag;
return callback(null, area);
}
}
@ -705,7 +706,7 @@ function scanFile(filePath, options, iterator, cb) {
// up to many seconds in time for larger files.
//
const chunkSize = 1024 * 64;
const buffer = new Buffer(chunkSize);
const buffer = Buffer.allocUnsafe(chunkSize);
fs.open(filePath, 'r', (err, fd) => {
if(err) {

View File

@ -150,11 +150,7 @@ exports.getModule = class FileBaseDownloadQueueManager extends MenuModule {
return cb(Errors.DoesNotExist('Queue view does not exist'));
}
const queueListFormat = this.menuConfig.config.queueListFormat || '{fileName} {byteSize}';
const focusQueueListFormat = this.menuConfig.config.focusQueueListFormat || queueListFormat;
queueView.setItems(this.dlQueue.items.map( queueItem => stringFormat(queueListFormat, queueItem) ) );
queueView.setFocusItems(this.dlQueue.items.map( queueItem => stringFormat(focusQueueListFormat, queueItem) ) );
queueView.setItems(this.dlQueue.items);
queueView.on('index update', idx => {
const fileEntry = this.dlQueue.items[idx];

View File

@ -1,9 +1,11 @@
/* jslint node: true */
'use strict';
const UserProps = require('./user_property.js');
// deps
const _ = require('lodash');
const uuidV4 = require('uuid/v4');
const _ = require('lodash');
const uuidV4 = require('uuid/v4');
module.exports = class FileBaseFilters {
constructor(client) {
@ -64,7 +66,7 @@ module.exports = class FileBaseFilters {
}
load() {
let filtersProperty = this.client.user.properties.file_base_filters;
let filtersProperty = this.client.user.properties[UserProps.FileBaseFilters];
let defaulted;
if(!filtersProperty) {
filtersProperty = JSON.stringify(FileBaseFilters.getBuiltInSystemFilters());
@ -90,7 +92,7 @@ module.exports = class FileBaseFilters {
}
persist(cb) {
return this.client.user.persistProperty('file_base_filters', JSON.stringify(this.filters), cb);
return this.client.user.persistProperty(UserProps.FileBaseFilters, JSON.stringify(this.filters), cb);
}
cleanTags(tags) {
@ -102,7 +104,7 @@ module.exports = class FileBaseFilters {
if(activeFilter) {
this.activeFilter = activeFilter;
this.client.user.persistProperty('file_base_filter_active_uuid', filterUuid);
this.client.user.persistProperty(UserProps.FileBaseFilterActiveUuid, filterUuid);
return true;
}
@ -129,11 +131,11 @@ module.exports = class FileBaseFilters {
}
static getActiveFilter(client) {
return new FileBaseFilters(client).get(client.user.properties.file_base_filter_active_uuid);
return new FileBaseFilters(client).get(client.user.properties[UserProps.FileBaseFilterActiveUuid]);
}
static getFileBaseLastViewedFileIdByUser(user) {
return parseInt((user.properties.user_file_base_last_viewed || 0));
return parseInt((user.properties[UserProps.FileBaseLastViewedId] || 0));
}
static setFileBaseLastViewedFileIdForUser(user, fileId, allowOlder, cb) {
@ -150,6 +152,6 @@ module.exports = class FileBaseFilters {
return;
}
return user.persistProperty('user_file_base_last_viewed', fileId, cb);
return user.persistProperty(UserProps.FileBaseLastViewedId, fileId, cb);
}
};

View File

@ -121,11 +121,7 @@ exports.getModule = class FileBaseWebDownloadQueueManager extends MenuModule {
return cb(Errors.DoesNotExist('Queue view does not exist'));
}
const queueListFormat = this.menuConfig.config.queueListFormat || '{webDlLink}';
const focusQueueListFormat = this.menuConfig.config.focusQueueListFormat || queueListFormat;
queueView.setItems(this.dlQueue.items.map( queueItem => stringFormat(queueListFormat, queueItem) ) );
queueView.setFocusItems(this.dlQueue.items.map( queueItem => stringFormat(focusQueueListFormat, queueItem) ) );
queueView.setItems(this.dlQueue.items);
queueView.on('index update', idx => {
const fileEntry = this.dlQueue.items[idx];

View File

@ -24,6 +24,7 @@ const {
const Config = require('./config.js').get;
const { getAddressedToInfo } = require('./mail_util.js');
const Events = require('./events.js');
const UserProps = require('./user_property.js');
// deps
const async = require('async');
@ -479,7 +480,7 @@ exports.FullScreenEditorModule = exports.getModule = class FullScreenEditorModul
}
Events.emit(Events.getSystemEvents().UserPostMessage, { user : this.client.user, areaTag : this.message.areaTag });
return StatLog.incrementUserStat(this.client.user, 'post_count', 1, cb);
return StatLog.incrementUserStat(this.client.user, UserProps.MessagePostCount, 1, cb);
}
redrawFooter(options, cb) {
@ -542,7 +543,7 @@ exports.FullScreenEditorModule = exports.getModule = class FullScreenEditorModul
theme.displayThemedAsset(
art[n],
self.client,
{ font : self.menuConfig.font, acsCondMember : 'art' },
{ font : self.menuConfig.font },
function displayed(err) {
next(err);
}
@ -622,7 +623,7 @@ exports.FullScreenEditorModule = exports.getModule = class FullScreenEditorModul
theme.displayThemedAsset(
art[n],
self.client,
{ font : self.menuConfig.font, acsCondMember : 'art' },
{ font : self.menuConfig.font },
function displayed(err, artData) {
if(artData) {
mciData[n] = artData;
@ -738,7 +739,7 @@ exports.FullScreenEditorModule = exports.getModule = class FullScreenEditorModul
const fromView = self.viewControllers.header.getView(MciViewIds.header.from);
const area = getMessageAreaByTag(self.messageAreaTag);
if(area && area.realNames) {
fromView.setText(self.client.user.properties.real_name || self.client.user.username);
fromView.setText(self.client.user.properties[UserProps.RealName] || self.client.user.username);
} else {
fromView.setText(self.client.user.username);
}

View File

@ -7,6 +7,7 @@ const StatLog = require('./stat_log.js');
const User = require('./user.js');
const sysDb = require('./database.js').dbs.system;
const { Errors } = require('./enig_error.js');
const UserProps = require('./user_property.js');
// deps
const moment = require('moment');
@ -165,7 +166,7 @@ exports.getModule = class LastCallersModule extends MenuModule {
loadUserForHistoryItems(loginHistory, cb) {
const getPropOpts = {
names : [ 'real_name', 'location', 'affiliation' ]
names : [ UserProps.RealName, UserProps.Location, UserProps.Affiliations ]
};
const actionIndicatorNames = _.map(this.actionIndicators, (v, k) => k);
@ -185,9 +186,9 @@ exports.getModule = class LastCallersModule extends MenuModule {
item.userName = item.text = userName;
User.loadProperties(item.userId, getPropOpts, (err, props) => {
item.location = (props && props.location) || '';
item.affiliation = item.affils = (props && props.affiliation) || '';
item.realName = (props && props.real_name) || '';
item.location = (props && props[UserProps.Location]) || '';
item.affiliation = item.affils = (props && props[UserProps.Affiliations]) || '';
item.realName = (props && props[UserProps.RealName]) || '';
if(!indicatorSumsSql) {
return next(null, item);

View File

@ -6,6 +6,7 @@ const conf = require('./config.js');
const logger = require('./logger.js');
const ServerModule = require('./server_module.js').ServerModule;
const clientConns = require('./client_connections.js');
const UserProps = require('./user_property.js');
// deps
const _ = require('lodash');
@ -25,12 +26,12 @@ module.exports = class LoginServerModule extends ServerModule {
//
const preLoginTheme = _.get(conf.config, 'theme.preLogin');
if('*' === preLoginTheme) {
client.user.properties.theme_id = theme.getRandomTheme() || '';
client.user.properties[UserProps.ThemeId] = theme.getRandomTheme() || '';
} else {
client.user.properties.theme_id = preLoginTheme;
client.user.properties[UserProps.ThemeId] = preLoginTheme;
}
theme.setClientTheme(client, client.user.properties.theme_id);
theme.setClientTheme(client, client.user.properties[UserProps.ThemeId]);
return cb(null); // note: currently useless to use cb here - but this may change...again...
}

View File

@ -47,6 +47,11 @@ exports.MenuModule = class MenuModule extends PluginModule {
const mciData = {};
let pausePosition;
const hasArt = () => {
return _.isString(self.menuConfig.art) ||
(Array.isArray(self.menuConfig.art) && _.has(self.menuConfig.art[0], 'acs'));
};
async.series(
[
function beforeArtInterrupt(callback) {
@ -56,7 +61,7 @@ exports.MenuModule = class MenuModule extends PluginModule {
return self.beforeArt(callback);
},
function displayMenuArt(callback) {
if(!_.isString(self.menuConfig.art)) {
if(!hasArt()) {
return callback(null);
}

View File

@ -8,6 +8,7 @@ const Message = require('./message.js');
const Log = require('./logger.js').log;
const msgNetRecord = require('./msg_network.js').recordMessage;
const sortAreasOrConfs = require('./conf_area_util.js').sortAreasOrConfs;
const UserProps = require('./user_property.js');
// deps
const async = require('async');
@ -222,8 +223,8 @@ function changeMessageConference(client, confTag, cb) {
},
function changeConferenceAndArea(conf, areaInfo, callback) {
const newProps = {
message_conf_tag : confTag,
message_area_tag : areaInfo.areaTag,
[ UserProps.MessageConfTag ] : confTag,
[ UserProps.MessageAreaTag ] : areaInfo.areaTag,
};
client.user.persistProperties(newProps, err => {
callback(err, conf, areaInfo);
@ -262,11 +263,11 @@ function changeMessageAreaWithOptions(client, areaTag, options, cb) {
},
function changeArea(area, callback) {
if(true === options.persist) {
client.user.persistProperty('message_area_tag', areaTag, function persisted(err) {
client.user.persistProperty(UserProps.MessageAreaTag, areaTag, function persisted(err) {
return callback(err, area);
});
} else {
client.user.properties['message_area_tag'] = areaTag;
client.user.properties[UserProps.MessageAreaTag] = areaTag;
return callback(null, area);
}
}
@ -303,8 +304,8 @@ function tempChangeMessageConfAndArea(client, areaTag) {
return false;
}
client.user.properties.message_conf_tag = confTag;
client.user.properties.message_area_tag = areaTag;
client.user.properties[UserProps.MessageConfTag] = confTag;
client.user.properties[UserProps.MessageAreaTag] = areaTag;
return true;
}
@ -353,13 +354,19 @@ function getNewMessagesInAreaForUser(userId, areaTag, cb) {
});
}
function getMessageListForArea(client, areaTag, cb) {
const filter = {
areaTag,
resultType : 'messageList',
sort : 'messageId',
order : 'ascending',
};
function getMessageListForArea(client, areaTag, filter, cb)
{
if(!cb && _.isFunction(filter)) {
cb = filter;
filter = {
areaTag,
resultType : 'messageList',
sort : 'messageId',
order : 'ascending'
};
} else {
Object.assign(filter, { areaTag } );
}
if(Message.isPrivateAreaTag(areaTag)) {
filter.privateTagUserId = client.user.userId;

View File

@ -4,6 +4,8 @@
const paths = require('path');
const os = require('os');
const moment = require('moment');
const packageJson = require('../package.json');
exports.isProduction = isProduction;
@ -57,4 +59,4 @@ function valueAsArray(value) {
return [];
}
return Array.isArray(value) ? value : [ value ];
}
}

View File

@ -2,8 +2,10 @@
'use strict';
const messageArea = require('../core/message_area.js');
const { get } = require('lodash');
const UserProps = require('./user_property.js');
// deps
const { get } = require('lodash');
exports.MessageAreaConfTempSwitcher = Sup => class extends Sup {
@ -15,8 +17,8 @@ exports.MessageAreaConfTempSwitcher = Sup => class extends Sup {
if(recordPrevious) {
this.prevMessageConfAndArea = {
confTag : this.client.user.properties.message_conf_tag,
areaTag : this.client.user.properties.message_area_tag,
confTag : this.client.user.properties[UserProps.MessageConfTag],
areaTag : this.client.user.properties[UserProps.MessageAreaTag],
};
}
@ -27,8 +29,8 @@ exports.MessageAreaConfTempSwitcher = Sup => class extends Sup {
tempMessageConfAndAreaRestore() {
if(this.prevMessageConfAndArea) {
this.client.user.properties.message_conf_tag = this.prevMessageConfAndArea.confTag;
this.client.user.properties.message_area_tag = this.prevMessageConfAndArea.areaTag;
this.client.user.properties[UserProps.MessageConfTag] = this.prevMessageConfAndArea.confTag;
this.client.user.properties[UserProps.MessageAreaTag] = this.prevMessageConfAndArea.areaTag;
}
}
};

View File

@ -5,6 +5,7 @@
const { MenuModule } = require('./menu_module.js');
const messageArea = require('./message_area.js');
const { Errors } = require('./enig_error.js');
const UserProps = require('./user_property.js');
// deps
const async = require('async');
@ -110,7 +111,7 @@ exports.getModule = class MessageAreaListModule extends MenuModule {
initList() {
let index = 1;
this.messageAreas = messageArea.getSortedAvailMessageAreasByConfTag(
this.client.user.properties.message_conf_tag,
this.client.user.properties[UserProps.MessageConfTag],
{ client : this.client }
).map(area => {
return {

View File

@ -3,6 +3,7 @@
const FullScreenEditorModule = require('./fse.js').FullScreenEditorModule;
const persistMessage = require('./message_area.js').persistMessage;
const UserProps = require('./user_property.js');
const _ = require('lodash');
const async = require('async');
@ -58,8 +59,10 @@ exports.getModule = class AreaPostFSEModule extends FullScreenEditorModule {
}
enter() {
if(_.isString(this.client.user.properties.message_area_tag) && !_.isString(this.messageAreaTag)) {
this.messageAreaTag = this.client.user.properties.message_area_tag;
if(_.isString(this.client.user.properties[UserProps.MessageAreaTag]) &&
!_.isString(this.messageAreaTag))
{
this.messageAreaTag = this.client.user.properties[UserProps.MessageAreaTag];
}
super.enter();

View File

@ -8,6 +8,7 @@ const messageArea = require('./message_area.js');
const MessageAreaConfTempSwitcher = require('./mod_mixins.js').MessageAreaConfTempSwitcher;
const Errors = require('./enig_error.js').Errors;
const Message = require('./message.js');
const UserProps = require('./user_property.js');
// deps
const async = require('async');
@ -167,7 +168,7 @@ exports.getModule = class MessageListModule extends MessageAreaConfTempSwitcher(
if(this.config.messageAreaTag) {
this.tempMessageConfAndAreaSwitch(this.config.messageAreaTag);
} else {
this.config.messageAreaTag = this.client.user.properties.message_area_tag;
this.config.messageAreaTag = this.client.user.properties[UserProps.MessageAreaTag];
}
}
}

View File

@ -8,9 +8,14 @@ const theme = require('./theme.js');
const login = require('./system_menu_method.js').login;
const Config = require('./config.js').get;
const messageArea = require('./message_area.js');
const {
getISOTimestampString
} = require('./database.js');
const UserProps = require('./user_property.js');
// deps
const _ = require('lodash');
const moment = require('moment');
exports.moduleInfo = {
name : 'NUA',
@ -80,20 +85,20 @@ exports.getModule = class NewUserAppModule extends MenuModule {
areaTag = areaTag || '';
newUser.properties = {
real_name : formData.value.realName,
birthdate : new Date(Date.parse(formData.value.birthdate)).toISOString(), // :TODO: Use moment & explicit ISO string format
sex : formData.value.sex,
location : formData.value.location,
affiliation : formData.value.affils,
email_address : formData.value.email,
web_address : formData.value.web,
account_created : new Date().toISOString(), // :TODO: Use moment & explicit ISO string format
[ UserProps.RealName ] : formData.value.realName,
[ UserProps.Birthdate ] : getISOTimestampString(formData.value.birthdate),
[ UserProps.Sex ] : formData.value.sex,
[ UserProps.Location ] : formData.value.location,
[ UserProps.Affiliations ] : formData.value.affils,
[ UserProps.EmailAddress ] : formData.value.email,
[ UserProps.WebAddress ] : formData.value.web,
[ UserProps.AccountCreated ] : getISOTimestampString(),
message_conf_tag : confTag,
message_area_tag : areaTag,
[ UserProps.MessageConfTag ] : confTag,
[ UserProps.MessageAreaTag ] : areaTag,
term_height : self.client.term.termHeight,
term_width : self.client.term.termWidth,
[ UserProps.TermHeight ] : self.client.term.termHeight,
[ UserProps.TermWidth ] : self.client.term.termWidth,
// :TODO: Other defaults
// :TODO: should probably have a place to create defaults/etc.
@ -101,9 +106,9 @@ exports.getModule = class NewUserAppModule extends MenuModule {
const defaultTheme = _.get(config, 'theme.default');
if('*' === defaultTheme) {
newUser.properties.theme_id = theme.getRandomTheme();
newUser.properties[UserProps.ThemeId] = theme.getRandomTheme();
} else {
newUser.properties.theme_id = defaultTheme;
newUser.properties[UserProps.ThemeId] = defaultTheme;
}
// :TODO: User.create() should validate email uniqueness!
@ -133,7 +138,7 @@ exports.getModule = class NewUserAppModule extends MenuModule {
};
}
if(User.AccountStatus.inactive === self.client.user.properties.account_status) {
if(User.AccountStatus.inactive === self.client.user.properties[UserProps.AccountStatus]) {
return self.gotoMenu(extraArgs.inactive, cb);
} else {
//

View File

@ -38,7 +38,7 @@ function getAnswers(questions, cb) {
const ConfigIncludeKeys = [
'theme',
'users.preAuthIdleLogoutSeconds', 'users.idleLogoutSeconds',
'users.newUserNames',
'users.newUserNames', 'users.failedLogin', 'users.unlockAtEmailPwReset',
'paths.logs',
'loginServers',
'contentServers',

View File

@ -26,10 +26,11 @@ commands:
actions:
pw USERNAME PASSWORD set password to PASSWORD for USERNAME
rm USERNAME permanantely removes USERNAME user from system
rm USERNAME permanently removes USERNAME user from system
activate USERNAME sets USERNAME's status to active
deactivate USERNAME sets USERNAME's status to deactive
deactivate USERNAME sets USERNAME's status to inactive
disable USERNAME sets USERNAME's status to disabled
lock USERNAME sets USERNAME's status to locked
group USERNAME [+|-]GROUP adds (+) or removes (-) user from GROUP
`,
@ -57,7 +58,7 @@ cat args:
actions:
scan AREA_TAG[@STORAGE_TAG] scan specified area
may also contain optional GLOB as last parameter,
for examle: scan some_area *.zip
for example: scan some_area *.zip
info CRITERIA display information about areas and/or files
where CRITERIA is one of the following:

View File

@ -17,7 +17,7 @@ module.exports = function() {
process.exitCode = ExitCodes.SUCCESS;
if(true === argv.version) {
return console.info(require('../package.json').version);
return console.info(require('../../package.json').version);
}
if(0 === argv._.length ||

View File

@ -8,25 +8,13 @@ const argv = require('./oputil_common.js').argv;
const initConfigAndDatabases = require('./oputil_common.js').initConfigAndDatabases;
const getHelpFor = require('./oputil_help.js').getHelpFor;
const Errors = require('../enig_error.js').Errors;
const UserProps = require('../user_property.js');
const async = require('async');
const _ = require('lodash');
exports.handleUserCommand = handleUserCommand;
function getUser(userName, cb) {
const User = require('../../core/user.js');
User.getUserIdAndName(userName, (err, userId) => {
if(err) {
process.exitCode = ExitCodes.BAD_ARGS;
return cb(err);
}
const u = new User();
u.userId = userId;
return cb(null, u);
});
}
function initAndGetUser(userName, cb) {
async.waterfall(
[
@ -34,12 +22,12 @@ function initAndGetUser(userName, cb) {
initConfigAndDatabases(callback);
},
function getUserObject(callback) {
getUser(userName, (err, user) => {
const User = require('../../core/user.js');
User.getUserIdAndName(userName, (err, userId) => {
if(err) {
process.exitCode = ExitCodes.BAD_ARGS;
return callback(err);
}
return callback(null, user);
return User.getUser(userId, callback);
});
}
],
@ -55,15 +43,38 @@ function setAccountStatus(user, status) {
}
const AccountStatus = require('../../core/user.js').AccountStatus;
status = {
activate : AccountStatus.active,
deactivate : AccountStatus.inactive,
disable : AccountStatus.disabled,
lock : AccountStatus.locked,
}[status];
const statusDesc = _.invert(AccountStatus)[status];
user.persistProperty('account_status', status, err => {
if(err) {
process.exitCode = ExitCodes.ERROR;
console.error(err.message);
} else {
console.info(`User status set to ${statusDesc}`);
async.series(
[
(callback) => {
return user.persistProperty(UserProps.AccountStatus, status, callback);
},
(callback) => {
if(AccountStatus.active !== status) {
return callback(null);
}
return user.unlockAccount(callback);
}
],
err => {
if(err) {
process.exitCode = ExitCodes.ERROR;
console.error(err.message);
} else {
console.info(`User status set to ${statusDesc}`);
}
}
});
);
}
function setUserPassword(user) {
@ -147,21 +158,6 @@ function modUserGroups(user) {
}
}
function activateUser(user) {
const AccountStatus = require('../../core/user.js').AccountStatus;
return setAccountStatus(user, AccountStatus.active);
}
function deactivateUser(user) {
const AccountStatus = require('../../core/user.js').AccountStatus;
return setAccountStatus(user, AccountStatus.inactive);
}
function disableUser(user) {
const AccountStatus = require('../../core/user.js').AccountStatus;
return setAccountStatus(user, AccountStatus.disabled);
}
function handleUserCommand() {
function errUsage() {
return printUsageAndSetExitCode(getHelpFor('User'), ExitCodes.ERROR);
@ -195,11 +191,12 @@ function handleUserCommand() {
del : removeUser,
delete : removeUser,
activate : activateUser,
deactivate : deactivateUser,
disable : disableUser,
activate : setAccountStatus,
deactivate : setAccountStatus,
disable : setAccountStatus,
lock : setAccountStatus,
group : modUserGroups,
}[action] || errUsage)(user);
}[action] || errUsage)(user, action);
});
}

View File

@ -2,17 +2,18 @@
'use strict';
// ENiGMA½
const Config = require('./config.js').get;
const Log = require('./logger.js').log;
const Config = require('./config.js').get;
const Log = require('./logger.js').log;
const {
getMessageAreaByTag,
getMessageConferenceByTag
} = require('./message_area.js');
const clientConnections = require('./client_connections.js');
const StatLog = require('./stat_log.js');
const FileBaseFilters = require('./file_base_filter.js');
const { formatByteSize } = require('./string_util.js');
const ANSI = require('./ansi_term.js');
} = require('./message_area.js');
const clientConnections = require('./client_connections.js');
const StatLog = require('./stat_log.js');
const FileBaseFilters = require('./file_base_filter.js');
const { formatByteSize } = require('./string_util.js');
const ANSI = require('./ansi_term.js');
const UserProps = require('./user_property.js');
// deps
const packageJson = require('../package.json');
@ -80,62 +81,66 @@ const PREDEFINED_MCI_GENERATORS = {
UN : function userName(client) { return client.user.username; },
UI : function userId(client) { return client.user.userId.toString(); },
UG : function groups(client) { return _.values(client.user.groups).join(', '); },
UR : function realName(client) { return userStatAsString(client, 'real_name', ''); },
LO : function location(client) { return userStatAsString(client, 'location', ''); },
UR : function realName(client) { return userStatAsString(client, UserProps.RealName, ''); },
LO : function location(client) { return userStatAsString(client, UserProps.Location, ''); },
UA : function age(client) { return client.user.getAge().toString(); },
BD : function birthdate(client) { return moment(client.user.properties.birthdate).format(client.currentTheme.helpers.getDateFormat()); }, // iNiQUiTY
US : function sex(client) { return userStatAsString(client, 'sex', ''); },
UE : function emailAddres(client) { return userStatAsString(client, 'email_address', ''); },
UW : function webAddress(client) { return userStatAsString(client, 'web_address', ''); },
UF : function affils(client) { return userStatAsString(client, 'affiliation', ''); },
UT : function themeId(client) { return userStatAsString(client, 'theme_id', ''); },
UC : function loginCount(client) { return userStatAsString(client, 'login_count', 0); },
BD : function birthdate(client) { // iNiQUiTY
return moment(client.user.properties[UserProps.Birthdate]).format(client.currentTheme.helpers.getDateFormat());
},
US : function sex(client) { return userStatAsString(client, UserProps.Sex, ''); },
UE : function emailAddres(client) { return userStatAsString(client, UserProps.EmailAddress, ''); },
UW : function webAddress(client) { return userStatAsString(client, UserProps.WebAddress, ''); },
UF : function affils(client) { return userStatAsString(client, UserProps.Affiliations, ''); },
UT : function themeId(client) { return userStatAsString(client, UserProps.ThemeId, ''); },
UC : function loginCount(client) { return userStatAsString(client, UserProps.LoginCount, 0); },
ND : function connectedNode(client) { return client.node.toString(); },
IP : function clientIpAddress(client) { return client.remoteAddress.replace(/^::ffff:/, ''); }, // convert any :ffff: IPv4's to 32bit version
ST : function serverName(client) { return client.session.serverName; },
FN : function activeFileBaseFilterName(client) {
const activeFilter = FileBaseFilters.getActiveFilter(client);
return activeFilter ? activeFilter.name : '';
return activeFilter ? activeFilter.name : '(Unknown)';
},
DN : function userNumDownloads(client) { return userStatAsString(client, 'dl_total_count', 0); }, // Obv/2
DN : function userNumDownloads(client) { return userStatAsString(client, UserProps.FileDlTotalCount, 0); }, // Obv/2
DK : function userByteDownload(client) { // Obv/2 uses DK=downloaded Kbytes
const byteSize = StatLog.getUserStatNum(client.user, 'dl_total_bytes');
const byteSize = StatLog.getUserStatNum(client.user, UserProps.FileDlTotalBytes);
return formatByteSize(byteSize, true); // true=withAbbr
},
UP : function userNumUploads(client) { return userStatAsString(client, 'ul_total_count', 0); }, // Obv/2
UP : function userNumUploads(client) { return userStatAsString(client, UserProps.FileUlTotalCount, 0); }, // Obv/2
UK : function userByteUpload(client) { // Obv/2 uses UK=uploaded Kbytes
const byteSize = StatLog.getUserStatNum(client.user, 'ul_total_bytes');
const byteSize = StatLog.getUserStatNum(client.user, UserProps.FileUlTotalBytes);
return formatByteSize(byteSize, true); // true=withAbbr
},
NR : function userUpDownRatio(client) { // Obv/2
return getUserRatio(client, 'ul_total_count', 'dl_total_count');
return getUserRatio(client, UserProps.FileUlTotalCount, UserProps.FileDlTotalCount);
},
KR : function userUpDownByteRatio(client) { // Obv/2 uses KR=upload/download Kbyte ratio
return getUserRatio(client, 'ul_total_bytes', 'dl_total_bytes');
return getUserRatio(client, UserProps.FileUlTotalBytes, UserProps.FileDlTotalBytes);
},
MS : function accountCreatedclient(client) { return moment(client.user.properties.account_created).format(client.currentTheme.helpers.getDateFormat()); },
PS : function userPostCount(client) { return userStatAsString(client, 'post_count', 0); },
PC : function userPostCallRatio(client) { return getUserRatio(client, 'post_count', 'login_count'); },
MS : function accountCreatedclient(client) {
return moment(client.user.properties[UserProps.AccountCreated]).format(client.currentTheme.helpers.getDateFormat());
},
PS : function userPostCount(client) { return userStatAsString(client, UserProps.MessagePostCount, 0); },
PC : function userPostCallRatio(client) { return getUserRatio(client, UserProps.MessagePostCount, UserProps.LoginCount); },
MD : function currentMenuDescription(client) {
return _.has(client, 'currentMenuModule.menuConfig.desc') ? client.currentMenuModule.menuConfig.desc : '';
},
MA : function messageAreaName(client) {
const area = getMessageAreaByTag(client.user.properties.message_area_tag);
const area = getMessageAreaByTag(client.user.properties[UserProps.MessageAreaTag]);
return area ? area.name : '';
},
MC : function messageConfName(client) {
const conf = getMessageConferenceByTag(client.user.properties.message_conf_tag);
const conf = getMessageConferenceByTag(client.user.properties[UserProps.MessageConfTag]);
return conf ? conf.name : '';
},
ML : function messageAreaDescription(client) {
const area = getMessageAreaByTag(client.user.properties.message_area_tag);
const area = getMessageAreaByTag(client.user.properties[UserProps.MessageAreaTag]);
return area ? area.desc : '';
},
CM : function messageConfDescription(client) {
const conf = getMessageConferenceByTag(client.user.properties.message_conf_tag);
const conf = getMessageConferenceByTag(client.user.properties[UserProps.MessageConfTag]);
return conf ? conf.desc : '';
},
@ -169,8 +174,9 @@ const PREDEFINED_MCI_GENERATORS = {
// Clean up CPU strings a bit for better display
//
return os.cpus()[0].model
.replace(/\(R\)|\(TM\)|processor|CPU/g, '')
.replace(/\s+(?= )/g, '');
.replace(/\(R\)|\(TM\)|processor|CPU/ig, '')
.replace(/\s+(?= )/g, '')
.trim();
},
// :TODO: MCI for core count, e.g. os.cpus().length

View File

@ -1746,7 +1746,7 @@ function FTNMessageScanTossModule() {
}
return callback(null, localInfo); // continue even if we couldn't find an old match
});
} else if(fileIds.legnth > 1) {
} else if(fileIds.length > 1) {
return callback(Errors.General(`More than one existing entry for TIC in ${localInfo.areaTag} ([${fileIds.join(', ')}])`));
} else {
return callback(null, localInfo);

View File

@ -17,6 +17,7 @@ const {
} = require('../../message_area.js');
const { sortAreasOrConfs } = require('../../conf_area_util.js');
const AnsiPrep = require('../../ansi_prep.js');
const { wordWrapText } = require('../../word_wrap.js');
// deps
const net = require('net');
@ -27,9 +28,10 @@ const moment = require('moment');
const ModuleInfo = exports.moduleInfo = {
name : 'Gopher',
desc : 'Gopher Server',
desc : 'A RFC-1436-ish Gopher Server',
author : 'NuSkooler',
packageName : 'codes.l33t.enigma.gopher.server',
notes : 'https://tools.ietf.org/html/rfc1436',
};
const Message = require('../../message.js');
@ -158,7 +160,7 @@ exports.getModule = class GopherModule extends ServerModule {
defaultGenerator(selectorMatch, cb) {
this.log.debug( { selector : selectorMatch[0] }, 'Serving default content');
let bannerFile = _.get(Config(), 'contentServers.gopher.bannerFile', 'startup_banner.asc');
let bannerFile = _.get(Config(), 'contentServers.gopher.bannerFile', 'gopher_banner.asc');
bannerFile = paths.isAbsolute(bannerFile) ? bannerFile : paths.join(__dirname, '../../../misc', bannerFile);
fs.readFile(bannerFile, 'utf8', (err, banner) => {
if(err) {
@ -182,21 +184,43 @@ exports.getModule = class GopherModule extends ServerModule {
}
prepareMessageBody(body, cb) {
//
// From RFC-1436:
// "User display strings are intended to be displayed on a line on a
// typical screen for a user's viewing pleasure. While many screens can
// accommodate 80 character lines, some space is needed to display a tag
// of some sort to tell the user what sort of item this is. Because of
// this, the user display string should be kept under 70 characters in
// length. Clients may truncate to a length convenient to them."
//
// Messages on BBSes however, have generally been <= 79 characters. If we
// start wrapping earlier, things will generally be OK except:
// * When we're doing with FTN-style quoted lines
// * When dealing with ANSI/ASCII art
//
// Anyway, the spec says "should" and not MUST or even SHOULD! ...so, to
// to follow the KISS principle: Wrap at 79.
//
const WordWrapColumn = 79;
if(isAnsi(body)) {
AnsiPrep(
body,
{
cols : 79, // Gopher std. wants 70, but we'll have to deal with it.
forceLineTerm : true, // ensure each line is term'd
asciiMode : true, // export to ASCII
fillLines : false, // don't fill up to |cols|
cols : WordWrapColumn, // See notes above
forceLineTerm : true, // Ensure each line is term'd
asciiMode : true, // Export to ASCII
fillLines : false, // Don't fill up to |cols|
},
(err, prepped) => {
return cb(prepped || body);
}
);
} else {
return cb(cleanControlCodes(body, { all : true } ));
const prepped = splitTextAtTerms(cleanControlCodes(body, { all : true } ) )
.map(l => (wordWrapText(l, { width : WordWrapColumn } ).wrapped || []).join('\n'))
.join('\n');
return cb(prepped);
}
}
@ -225,7 +249,7 @@ exports.getModule = class GopherModule extends ServerModule {
return message.load( { uuid : msgUuid }, err => {
if(err) {
this.log.debug( { uuid : msgUuid }, 'Attempted access to non-existant message UUID!');
this.log.debug( { uuid : msgUuid }, 'Attempted access to non-existent message UUID!');
return this.notFoundGenerator(selectorMatch, cb);
}
@ -268,10 +292,17 @@ ${msgBody}
return this.notFoundGenerator(selectorMatch, cb);
}
return getMessageListForArea(null, areaTag, (err, msgList) => {
const filter = {
resultType : 'messageList',
sort : 'messageId',
order : 'descending', // we want newest messages first for Gopher
};
return getMessageListForArea(null, areaTag, filter, (err, msgList) => {
const response = [
this.makeItem(ItemTypes.InfoMessage, '-'.repeat(70)),
this.makeItem(ItemTypes.InfoMessage, `Messages in ${area.name}`),
this.makeItem(ItemTypes.InfoMessage, '(newest first)'),
this.makeItem(ItemTypes.InfoMessage, '-'.repeat(70)),
...msgList.map(msg => this.makeItem(
ItemTypes.TextFile,

View File

@ -10,6 +10,10 @@ const userLogin = require('../../user_login.js').userLogin;
const enigVersion = require('../../../package.json').version;
const theme = require('../../theme.js');
const stringFormat = require('../../string_format.js');
const {
Errors,
ErrorReasons
} = require('../../enig_error.js');
// deps
const ssh2 = require('ssh2');
@ -36,8 +40,6 @@ function SSHClient(clientConn) {
const self = this;
let loginAttempts = 0;
clientConn.on('authentication', function authAttempt(ctx) {
const username = ctx.username || '';
const password = ctx.password || '';
@ -52,26 +54,56 @@ function SSHClient(clientConn) {
return clientConn.end();
}
function alreadyLoggedIn(username) {
ctx.prompt(`${username} is already connected to the system. Terminating connection.\n(Press any key to continue)`);
function promptAndTerm(msg) {
if('keyboard-interactive' === ctx.method) {
ctx.prompt(msg);
}
return terminateConnection();
}
function accountAlreadyLoggedIn(username) {
return promptAndTerm(`${username} is already connected to the system. Terminating connection.\n(Press any key to continue)`);
}
function accountDisabled(username) {
return promptAndTerm(`${username} is disabled.\n(Press any key to continue)`);
}
function accountInactive(username) {
return promptAndTerm(`${username} is waiting for +op activation.\n(Press any key to continue)`);
}
function accountLocked(username) {
return promptAndTerm(`${username} is locked.\n(Press any key to continue)`);
}
function isSpecialHandleError(err) {
return [ ErrorReasons.AlreadyLoggedIn, ErrorReasons.Disabled, ErrorReasons.Inactive, ErrorReasons.Locked ].includes(err.reasonCode);
}
function handleSpecialError(err, username) {
switch(err.reasonCode) {
case ErrorReasons.AlreadyLoggedIn : return accountAlreadyLoggedIn(username);
case ErrorReasons.Inactive : return accountInactive(username);
case ErrorReasons.Disabled : return accountDisabled(username);
case ErrorReasons.Locked : return accountLocked(username);
default : return terminateConnection();
}
}
//
// If the system is open and |isNewUser| is true, the login
// sequence is hijacked in order to start the applicaiton process.
// sequence is hijacked in order to start the application process.
//
if(false === config.general.closedSystem && self.isNewUser) {
return ctx.accept();
}
if(username.length > 0 && password.length > 0) {
loginAttempts += 1;
userLogin(self, ctx.username, ctx.password, function authResult(err) {
if(err) {
if(err.existingConn) {
return alreadyLoggedIn(username);
if(isSpecialHandleError(err)) {
return handleSpecialError(err, username);
}
return ctx.reject(SSHClient.ValidAuthMethods);
@ -92,15 +124,13 @@ function SSHClient(clientConn) {
const interactivePrompt = { prompt : `${ctx.username}'s password: `, echo : false };
ctx.prompt(interactivePrompt, function retryPrompt(answers) {
loginAttempts += 1;
userLogin(self, username, (answers[0] || ''), err => {
if(err) {
if(err.existingConn) {
return alreadyLoggedIn(username);
if(isSpecialHandleError(err)) {
return handleSpecialError(err, username);
}
if(loginAttempts >= config.general.loginAttempts) {
if(Errors.BadLogin().code === err.code) {
return terminateConnection();
}

View File

@ -14,7 +14,7 @@ const {
updateMessageAreaLastReadId,
getMessageIdNewerThanTimestampByArea
} = require('./message_area.js');
const stringFormat = require('./string_format.js');
const UserProps = require('./user_property.js');
// deps
const async = require('async');
@ -153,11 +153,13 @@ exports.getModule = class SetNewScanDate extends MenuModule {
selections.push({
conf : {
confTag : conf.confTag,
text : conf.conf.name, // standard
name : conf.conf.name,
desc : conf.conf.desc,
},
area : {
areaTag : area.areaTag,
text : area.area.name, // standard
name : area.area.name,
desc : area.area.desc,
}
@ -168,19 +170,21 @@ exports.getModule = class SetNewScanDate extends MenuModule {
selections.unshift({
conf : {
confTag : '',
text : 'All conferences',
name : 'All conferences',
desc : 'All conferences',
},
area : {
areaTag : '',
text : 'All areas',
name : 'All areas',
desc : 'All areas',
}
});
// Find current conf/area & move it directly under "All"
const currConfTag = this.client.user.properties.message_conf_tag;
const currAreaTag = this.client.user.properties.message_area_tag;
const currConfTag = this.client.user.properties[UserProps.MessageConfTag];
const currAreaTag = this.client.user.properties[UserProps.MessageAreaTag];
if(currConfTag && currAreaTag) {
const confAreaIndex = selections.findIndex( confArea => {
return confArea.conf.confTag === currConfTag && confArea.area.areaTag === currAreaTag;
@ -236,14 +240,9 @@ exports.getModule = class SetNewScanDate extends MenuModule {
scanDateView.setText(today.format(scanDateFormat));
if('message' === self.target) {
const messageSelectionsFormat = self.menuConfig.config.messageSelectionsFormat || '{conf.name} - {area.name}';
const messageSelectionFocusFormat = self.menuConfig.config.messageSelectionFocusFormat || messageSelectionsFormat;
const targetSelectionView = vc.getView(MciViewIds.main.targetSelection);
targetSelectionView.setItems(self.targetSelections.map(targetSelection => stringFormat(messageSelectionFocusFormat, targetSelection)));
targetSelectionView.setFocusItems(self.targetSelections.map(targetSelection => stringFormat(messageSelectionFocusFormat, targetSelection)));
targetSelectionView.setItems(self.targetSelections);
targetSelectionView.setFocusItemIndex(0);
}

View File

@ -68,7 +68,7 @@ exports.getModule = class ShowArtModule extends MenuModule {
}
showByExtraArgs(cb) {
this.getArtKeyValue( (err, artSpec) => {
this.getArtKeyValue(this.config.key, (err, artSpec) => {
if(err) {
return cb(err);
}
@ -89,7 +89,7 @@ exports.getModule = class ShowArtModule extends MenuModule {
}
showByFileBaseArea(cb) {
this.getArtKeyValue( (err, key) => {
this.getArtKeyValue('areaTag', (err, key) => {
if(err) {
return cb(err);
}
@ -98,7 +98,7 @@ exports.getModule = class ShowArtModule extends MenuModule {
}
showByMessageConf(cb) {
this.getArtKeyValue( (err, key) => {
this.getArtKeyValue('confTag', (err, key) => {
if(err) {
return cb(err);
}
@ -107,7 +107,7 @@ exports.getModule = class ShowArtModule extends MenuModule {
}
showByMessageArea(cb) {
this.getArtKeyValue( (err, key) => {
this.getArtKeyValue('areaTag', (err, key) => {
if(err) {
return cb(err);
}
@ -133,8 +133,8 @@ exports.getModule = class ShowArtModule extends MenuModule {
return this.displaySingleArtWithOptions(artSpec, options, cb);
}
getArtKeyValue(cb) {
const key = this.config.key;
getArtKeyValue(defaultKey, cb) {
const key = this.config.key || defaultKey;
if(!_.isString(key)) {
return cb(Errors.MissingConfig('Config option "key" is required for method "extraArgs"'));
}

View File

@ -2,10 +2,12 @@
'use strict';
const sysDb = require('./database.js').dbs.system;
const {
getISOTimestampString
} = require('./database.js');
// deps
const _ = require('lodash');
const moment = require('moment');
/*
System Event Log & Stats
@ -68,6 +70,7 @@ class StatLog {
};
}
// :TODO: fix spelling :)
setNonPeristentSystemStat(statName, statValue) {
this.systemStats[statName] = statValue;
}
@ -148,7 +151,9 @@ class StatLog {
}
// the time "now" in the ISO format we use and love :)
get now() { return moment().format('YYYY-MM-DDTHH:mm:ss.SSSZ'); }
get now() {
return getISOTimestampString();
}
appendSystemLogEntry(logName, logValue, keep, keepType, cb) {
sysDb.run(

View File

@ -2,10 +2,12 @@
'use strict';
// ENiGMA½
const removeClient = require('./client_connections.js').removeClient;
const { removeClient } = require('./client_connections.js');
const ansiNormal = require('./ansi_term.js').normal;
const userLogin = require('./user_login.js').userLogin;
const { userLogin } = require('./user_login.js');
const messageArea = require('./message_area.js');
const { ErrorReasons } = require('./enig_error.js');
const UserProps = require('./user_property.js');
// deps
const _ = require('lodash');
@ -25,13 +27,23 @@ function login(callingMenu, formData, extraArgs, cb) {
userLogin(callingMenu.client, formData.value.username, formData.value.password, err => {
if(err) {
// login failure
if(err.existingConn && _.has(callingMenu, 'menuConfig.config.tooNodeMenu')) {
// already logged in with this user?
if(ErrorReasons.AlreadyLoggedIn === err.reasonCode &&
_.has(callingMenu, 'menuConfig.config.tooNodeMenu'))
{
return callingMenu.gotoMenu(callingMenu.menuConfig.config.tooNodeMenu, cb);
} else {
// Other error
return callingMenu.prevMenu(cb);
}
const ReasonsMenus = [
ErrorReasons.TooMany, ErrorReasons.Disabled, ErrorReasons.Inactive, ErrorReasons.Locked
];
if(ReasonsMenus.includes(err.reasonCode)) {
const menu = _.get(callingMenu, [ 'menuConfig', 'config', err.reasonCode.toLowerCase() ]);
return menu ? callingMenu.gotoMenu(menu, cb) : logoff(callingMenu, {}, {}, cb);
}
// Other error
return callingMenu.prevMenu(cb);
}
// success!
@ -94,7 +106,7 @@ function reloadMenu(menu, cb) {
function prevConf(callingMenu, formData, extraArgs, cb) {
const confs = messageArea.getSortedAvailMessageConferences(callingMenu.client);
const currIndex = confs.findIndex( e => e.confTag === callingMenu.client.user.properties.message_conf_tag) || confs.length;
const currIndex = confs.findIndex( e => e.confTag === callingMenu.client.user.properties[UserProps.MessageConfTag]) || confs.length;
messageArea.changeMessageConference(callingMenu.client, confs[currIndex - 1].confTag, err => {
if(err) {
@ -107,7 +119,7 @@ function prevConf(callingMenu, formData, extraArgs, cb) {
function nextConf(callingMenu, formData, extraArgs, cb) {
const confs = messageArea.getSortedAvailMessageConferences(callingMenu.client);
let currIndex = confs.findIndex( e => e.confTag === callingMenu.client.user.properties.message_conf_tag);
let currIndex = confs.findIndex( e => e.confTag === callingMenu.client.user.properties[UserProps.MessageConfTag]);
if(currIndex === confs.length - 1) {
currIndex = -1;
@ -123,8 +135,8 @@ function nextConf(callingMenu, formData, extraArgs, cb) {
}
function prevArea(callingMenu, formData, extraArgs, cb) {
const areas = messageArea.getSortedAvailMessageAreasByConfTag(callingMenu.client.user.properties.message_conf_tag);
const currIndex = areas.findIndex( e => e.areaTag === callingMenu.client.user.properties.message_area_tag) || areas.length;
const areas = messageArea.getSortedAvailMessageAreasByConfTag(callingMenu.client.user.properties[UserProps.MessageConfTag]);
const currIndex = areas.findIndex( e => e.areaTag === callingMenu.client.user.properties[UserProps.MessageAreaTag]) || areas.length;
messageArea.changeMessageArea(callingMenu.client, areas[currIndex - 1].areaTag, err => {
if(err) {
@ -136,8 +148,8 @@ function prevArea(callingMenu, formData, extraArgs, cb) {
}
function nextArea(callingMenu, formData, extraArgs, cb) {
const areas = messageArea.getSortedAvailMessageAreasByConfTag(callingMenu.client.user.properties.message_conf_tag);
let currIndex = areas.findIndex( e => e.areaTag === callingMenu.client.user.properties.message_area_tag);
const areas = messageArea.getSortedAvailMessageAreasByConfTag(callingMenu.client.user.properties[UserProps.MessageConfTag]);
let currIndex = areas.findIndex( e => e.areaTag === callingMenu.client.user.properties[UserProps.MessageAreaTag]);
if(currIndex === areas.length - 1) {
currIndex = -1;

View File

@ -13,7 +13,9 @@ const Errors = require('./enig_error.js').Errors;
const ErrorReasons = require('./enig_error.js').ErrorReasons;
const Events = require('./events.js');
const AnsiPrep = require('./ansi_prep.js');
const UserProps = require('./user_property.js');
// deps
const fs = require('graceful-fs');
const paths = require('path');
const async = require('async');
@ -38,7 +40,7 @@ function refreshThemeHelpers(theme) {
getPasswordChar : function() {
let pwChar = _.get(
theme,
'customization.defaults.general.passwordChar',
'customization.defaults.passwordChar',
Config().theme.passwordChar
);
@ -427,8 +429,8 @@ function getThemeArt(options, cb) {
// random
//
const config = Config();
if(!options.themeId && _.has(options, 'client.user.properties.theme_id')) {
options.themeId = options.client.user.properties.theme_id;
if(!options.themeId && _.has(options, [ 'client', 'user', 'properties', UserProps.ThemeId ])) {
options.themeId = options.client.user.properties[UserProps.ThemeId];
} else {
options.themeId = config.theme.default;
}
@ -682,8 +684,9 @@ function displayThemedAsset(assetSpec, client, options, cb) {
options = {};
}
if(Array.isArray(assetSpec) && _.isString(options.acsCondMember)) {
assetSpec = client.acs.getConditionalValue(assetSpec, options.acsCondMember);
if(Array.isArray(assetSpec)) {
const acsCondMember = options.acsCondMember || 'art';
assetSpec = client.acs.getConditionalValue(assetSpec, acsCondMember);
}
const artAsset = asset.getArtAsset(assetSpec);

View File

@ -187,10 +187,10 @@ module.exports = class TicFileInfo {
// send the file to be distributed and the accompanying TIC file.
// Some File processors (Allfix) only insert a line with this
// keyword when the file and the associated TIC file are to be
// file routed through a third sysem instead of being processed
// file routed through a third system instead of being processed
// by a file processor on that system. Others always insert it.
// Note that the To keyword may cause problems when the TIC file
// is proecessed by software that does not recognise it and
// is processed by software that does not recognize it and
// passes the line "as is" to other systems.
//
// Example: To 292/854

View File

@ -1,11 +1,18 @@
/* jslint node: true */
'use strict';
// ENiGMA½
const userDb = require('./database.js').dbs.user;
const Config = require('./config.js').get;
const userGroup = require('./user_group.js');
const Errors = require('./enig_error.js').Errors;
const {
Errors,
ErrorReasons
} = require('./enig_error.js');
const Events = require('./events.js');
const UserProps = require('./user_property.js');
const Log = require('./logger.js').log;
const StatLog = require('./stat_log.js');
// deps
const crypto = require('crypto');
@ -39,18 +46,31 @@ module.exports = class User {
static get StandardPropertyGroups() {
return {
password : [ 'pw_pbkdf2_salt', 'pw_pbkdf2_dk' ],
password : [ UserProps.PassPbkdf2Salt, UserProps.PassPbkdf2Dk ],
};
}
static get AccountStatus() {
return {
disabled : 0,
inactive : 1,
active : 2,
disabled : 0, // +op disabled
inactive : 1, // inactive, aka requires +op approval/activation
active : 2, // standard, active
locked : 3, // locked out (too many bad login attempts, etc.)
};
}
static isSamePasswordSlowCompare(passBuf1, passBuf2) {
if(passBuf1.length !== passBuf2.length) {
return false;
}
let c = 0;
for(let i = 0; i < passBuf1.length; i++) {
c |= passBuf1[i] ^ passBuf2[i];
}
return 0 === c;
}
isAuthenticated() {
return true === this.authenticated;
}
@ -60,16 +80,21 @@ module.exports = class User {
return false;
}
return this.hasValidPassword();
return this.hasValidPasswordProperties();
}
hasValidPassword() {
if(!this.properties || !this.properties.pw_pbkdf2_salt || !this.properties.pw_pbkdf2_dk) {
hasValidPasswordProperties() {
const salt = this.getProperty(UserProps.PassPbkdf2Salt);
const dk = this.getProperty(UserProps.PassPbkdf2Dk);
if(!salt || !dk ||
(salt.length !== User.PBKDF2.saltLen * 2) ||
(dk.length !== User.PBKDF2.keyLen * 2))
{
return false;
}
return ((this.properties.pw_pbkdf2_salt.length === User.PBKDF2.saltLen * 2) &&
(this.properties.pw_pbkdf2_dk.length === User.PBKDF2.keyLen * 2));
return true;
}
isRoot() {
@ -101,31 +126,85 @@ module.exports = class User {
return 10; // :TODO: Is this what we want?
}
processFailedLogin(userId, cb) {
async.waterfall(
[
(callback) => {
return User.getUser(userId, callback);
},
(tempUser, callback) => {
return StatLog.incrementUserStat(
tempUser,
UserProps.FailedLoginAttempts,
1,
(err, failedAttempts) => {
return callback(null, tempUser, failedAttempts);
}
);
},
(tempUser, failedAttempts, callback) => {
const lockAccount = _.get(Config(), 'users.failedLogin.lockAccount');
if(lockAccount > 0 && failedAttempts >= lockAccount) {
const props = {
[ UserProps.AccountStatus ] : User.AccountStatus.locked,
[ UserProps.AccountLockedTs ] : StatLog.now,
};
if(!_.has(tempUser.properties, UserProps.AccountLockedPrevStatus)) {
props[UserProps.AccountLockedPrevStatus] = tempUser.getProperty(UserProps.AccountStatus);
}
Log.info( { userId, failedAttempts }, '(Re)setting account to locked due to failed logins');
return tempUser.persistProperties(props, callback);
}
return cb(null);
}
],
err => {
return cb(err);
}
);
}
unlockAccount(cb) {
const prevStatus = this.getProperty(UserProps.AccountLockedPrevStatus);
if(!prevStatus) {
return cb(null); // nothing to do
}
this.persistProperty(UserProps.AccountStatus, prevStatus, err => {
if(err) {
return cb(err);
}
return this.removeProperties( [ UserProps.AccountLockedPrevStatus, UserProps.AccountLockedTs ], cb);
});
}
authenticate(username, password, cb) {
const self = this;
const cachedInfo = {};
const tempAuthInfo = {};
async.waterfall(
[
function fetchUserId(callback) {
// get user ID
User.getUserIdAndName(username, (err, uid, un) => {
cachedInfo.userId = uid;
cachedInfo.username = un;
tempAuthInfo.userId = uid;
tempAuthInfo.username = un;
return callback(err);
});
},
function getRequiredAuthProperties(callback) {
// fetch properties required for authentication
User.loadProperties(cachedInfo.userId, { names : User.StandardPropertyGroups.password }, (err, props) => {
User.loadProperties(tempAuthInfo.userId, { names : User.StandardPropertyGroups.password }, (err, props) => {
return callback(err, props);
});
},
function getDkWithSalt(props, callback) {
// get DK from stored salt and password provided
User.generatePasswordDerivedKey(password, props.pw_pbkdf2_salt, (err, dk) => {
return callback(err, dk, props.pw_pbkdf2_dk);
User.generatePasswordDerivedKey(password, props[UserProps.PassPbkdf2Salt], (err, dk) => {
return callback(err, dk, props[UserProps.PassPbkdf2Dk]);
});
},
function validateAuth(passDk, propsDk, callback) {
@ -135,30 +214,57 @@ module.exports = class User {
const passDkBuf = Buffer.from(passDk, 'hex');
const propsDkBuf = Buffer.from(propsDk, 'hex');
if(passDkBuf.length !== propsDkBuf.length) {
return callback(Errors.AccessDenied('Invalid password'));
}
let c = 0;
for(let i = 0; i < passDkBuf.length; i++) {
c |= passDkBuf[i] ^ propsDkBuf[i];
}
return callback(0 === c ? null : Errors.AccessDenied('Invalid password'));
return callback(User.isSamePasswordSlowCompare(passDkBuf, propsDkBuf) ?
null :
Errors.AccessDenied('Invalid password')
);
},
function initProps(callback) {
User.loadProperties(cachedInfo.userId, (err, allProps) => {
User.loadProperties(tempAuthInfo.userId, (err, allProps) => {
if(!err) {
cachedInfo.properties = allProps;
tempAuthInfo.properties = allProps;
}
return callback(err);
});
},
function checkAccountStatus(callback) {
const accountStatus = parseInt(tempAuthInfo.properties[UserProps.AccountStatus], 10);
if(User.AccountStatus.disabled === accountStatus) {
return callback(Errors.AccessDenied('Account disabled', ErrorReasons.Disabled));
}
if(User.AccountStatus.inactive === accountStatus) {
return callback(Errors.AccessDenied('Account inactive', ErrorReasons.Inactive));
}
if(User.AccountStatus.locked === accountStatus) {
const autoUnlockMinutes = _.get(Config(), 'users.failedLogin.autoUnlockMinutes');
const lockedTs = moment(tempAuthInfo.properties[UserProps.AccountLockedTs]);
if(autoUnlockMinutes && lockedTs.isValid()) {
const minutesSinceLocked = moment().diff(lockedTs, 'minutes');
if(minutesSinceLocked >= autoUnlockMinutes) {
// allow the login - we will clear any lock there
Log.info(
{ username, userId : tempAuthInfo.userId, lockedAt : lockedTs.format() },
'Locked account will now be unlocked due to auto-unlock minutes policy'
);
return callback(null);
}
}
return callback(Errors.AccessDenied('Account is locked', ErrorReasons.Locked));
}
// anything else besides active is still not allowed
if(User.AccountStatus.active !== accountStatus) {
return callback(Errors.AccessDenied('Account is not active'));
}
return callback(null);
},
function initGroups(callback) {
userGroup.getGroupsForUser(cachedInfo.userId, (err, groups) => {
userGroup.getGroupsForUser(tempAuthInfo.userId, (err, groups) => {
if(!err) {
cachedInfo.groups = groups;
tempAuthInfo.groups = groups;
}
return callback(err);
@ -166,15 +272,44 @@ module.exports = class User {
}
],
err => {
if(!err) {
self.userId = cachedInfo.userId;
self.username = cachedInfo.username;
self.properties = cachedInfo.properties;
self.groups = cachedInfo.groups;
if(err) {
//
// If we failed login due to something besides an inactive or disabled account,
// we need to update failure status and possibly lock the account.
//
// If locked already, update the lock timestamp -- ie, extend the lockout period.
//
if(![ErrorReasons.Disabled, ErrorReasons.Inactive].includes(err.reasonCode) && tempAuthInfo.userId) {
self.processFailedLogin(tempAuthInfo.userId, persistErr => {
if(persistErr) {
Log.warn( { error : persistErr.message }, 'Failed to persist failed login information');
}
return cb(err); // pass along original error
});
} else {
return cb(err);
}
} else {
// everything checks out - load up info
self.userId = tempAuthInfo.userId;
self.username = tempAuthInfo.username;
self.properties = tempAuthInfo.properties;
self.groups = tempAuthInfo.groups;
self.authenticated = true;
}
return cb(err);
self.removeProperty(UserProps.FailedLoginAttempts);
//
// We need to *revert* any locked status back to
// the user's previous status & clean up props.
//
self.unlockAccount(unlockErr => {
if(unlockErr) {
Log.warn( { error : unlockErr.message }, 'Failed to unlock account');
}
return cb(null);
});
}
}
);
}
@ -190,7 +325,7 @@ module.exports = class User {
const self = this;
// :TODO: set various defaults, e.g. default activation status, etc.
self.properties.account_status = config.users.requireActivation ? User.AccountStatus.inactive : User.AccountStatus.active;
self.properties[UserProps.AccountStatus] = config.users.requireActivation ? User.AccountStatus.inactive : User.AccountStatus.active;
async.waterfall(
[
@ -211,7 +346,7 @@ module.exports = class User {
// Do not require activation for userId 1 (root/admin)
if(User.RootUserID === self.userId) {
self.properties.account_status = User.AccountStatus.active;
self.properties[UserProps.AccountStatus] = User.AccountStatus.active;
}
return callback(null, trans);
@ -224,8 +359,8 @@ module.exports = class User {
return callback(err);
}
self.properties.pw_pbkdf2_salt = info.salt;
self.properties.pw_pbkdf2_dk = info.dk;
self.properties[UserProps.PassPbkdf2Salt] = info.salt;
self.properties[UserProps.PassPbkdf2Dk] = info.dk;
return callback(null, trans);
});
},
@ -289,20 +424,32 @@ module.exports = class User {
);
}
static persistPropertyByUserId(userId, propName, propValue, cb) {
userDb.run(
`REPLACE INTO user_property (user_id, prop_name, prop_value)
VALUES (?, ?, ?);`,
[ userId, propName, propValue ],
err => {
if(cb) {
return cb(err, propValue);
}
}
);
}
getProperty(propName) {
return this.properties[propName];
}
getPropertyAsNumber(propName) {
return parseInt(this.getProperty(propName), 10);
}
persistProperty(propName, propValue, cb) {
// update live props
this.properties[propName] = propValue;
userDb.run(
`REPLACE INTO user_property (user_id, prop_name, prop_value)
VALUES (?, ?, ?);`,
[ this.userId, propName, propValue ],
err => {
if(cb) {
return cb(err);
}
}
);
return User.persistPropertyByUserId(this.userId, propName, propValue, cb);
}
removeProperty(propName, cb) {
@ -321,6 +468,15 @@ module.exports = class User {
);
}
removeProperties(propNames, cb) {
async.each(propNames, (name, next) => {
return this.removeProperty(name, next);
},
err => {
return cb(err);
});
}
persistProperties(properties, transOrDb, cb) {
if(!_.isFunction(cb) && _.isFunction(transOrDb)) {
cb = transOrDb;
@ -360,8 +516,8 @@ module.exports = class User {
}
const newProperties = {
pw_pbkdf2_salt : info.salt,
pw_pbkdf2_dk : info.dk,
[ UserProps.PassPbkdf2Salt ] : info.salt,
[ UserProps.PassPbkdf2Dk ] : info.dk,
};
this.persistProperties(newProperties, err => {
@ -371,8 +527,9 @@ module.exports = class User {
}
getAge() {
if(_.has(this.properties, 'birthdate')) {
return moment().diff(this.properties.birthdate, 'years');
const birthdate = this.getProperty(UserProps.Birthdate);
if(birthdate) {
return moment().diff(birthdate, 'years');
}
}
@ -439,7 +596,7 @@ module.exports = class User {
WHERE id = (
SELECT user_id
FROM user_property
WHERE prop_name='real_name' AND prop_value LIKE ?
WHERE prop_name='${UserProps.RealName}' AND prop_value LIKE ?
);`,
[ realName ],
(err, row) => {

View File

@ -1,11 +1,17 @@
/* jslint node: true */
'use strict';
// ENiGMA½
const MenuModule = require('./menu_module.js').MenuModule;
const ViewController = require('./view_controller.js').ViewController;
const theme = require('./theme.js');
const sysValidate = require('./system_view_validate.js');
const UserProps = require('./user_property.js');
const {
getISOTimestampString
} = require('./database.js');
// deps
const async = require('async');
const assert = require('assert');
const _ = require('lodash');
@ -49,7 +55,7 @@ exports.getModule = class UserConfigModule extends MenuModule {
//
// If nothing changed, we know it's OK
//
if(self.client.user.properties.email_address.toLowerCase() === data.toLowerCase()) {
if(self.client.user.properties[UserProps.EmailAddress].toLowerCase() === data.toLowerCase()) {
return cb(null);
}
@ -101,15 +107,15 @@ exports.getModule = class UserConfigModule extends MenuModule {
assert(formData.value.password === formData.value.passwordConfirm);
const newProperties = {
real_name : formData.value.realName,
birthdate : new Date(Date.parse(formData.value.birthdate)).toISOString(),
sex : formData.value.sex,
location : formData.value.location,
affiliation : formData.value.affils,
email_address : formData.value.email,
web_address : formData.value.web,
term_height : formData.value.termHeight.toString(),
theme_id : self.availThemeInfo[formData.value.theme].themeId,
[ UserProps.RealName ] : formData.value.realName,
[ UserProps.Birthdate ] : getISOTimestampString(formData.value.birthdate),
[ UserProps.Sex ] : formData.value.sex,
[ UserProps.Location ] : formData.value.location,
[ UserProps.Affiliations ] : formData.value.affils,
[ UserProps.EmailAddress ] : formData.value.email,
[ UserProps.WebAddress ] : formData.value.web,
[ UserProps.TermHeight ] : formData.value.termHeight.toString(),
[ UserProps.ThemeId ] : self.availThemeInfo[formData.value.theme].themeId,
};
// runtime set theme
@ -176,22 +182,22 @@ exports.getModule = class UserConfigModule extends MenuModule {
}), 'name');
currentThemeIdIndex = Math.max(0, _.findIndex(self.availThemeInfo, function cmp(ti) {
return ti.themeId === self.client.user.properties.theme_id;
return ti.themeId === self.client.user.properties[UserProps.ThemeId];
}));
callback(null);
},
function populateViews(callback) {
var user = self.client.user;
const user = self.client.user;
self.setViewText('menu', MciCodeIds.RealName, user.properties.real_name);
self.setViewText('menu', MciCodeIds.BirthDate, moment(user.properties.birthdate).format('YYYYMMDD'));
self.setViewText('menu', MciCodeIds.Sex, user.properties.sex);
self.setViewText('menu', MciCodeIds.Loc, user.properties.location);
self.setViewText('menu', MciCodeIds.Affils, user.properties.affiliation);
self.setViewText('menu', MciCodeIds.Email, user.properties.email_address);
self.setViewText('menu', MciCodeIds.Web, user.properties.web_address);
self.setViewText('menu', MciCodeIds.TermHeight, user.properties.term_height.toString());
self.setViewText('menu', MciCodeIds.RealName, user.properties[UserProps.RealName]);
self.setViewText('menu', MciCodeIds.BirthDate, moment(user.properties[UserProps.Birthdate]).format('YYYYMMDD'));
self.setViewText('menu', MciCodeIds.Sex, user.properties[UserProps.Sex]);
self.setViewText('menu', MciCodeIds.Loc, user.properties[UserProps.Location]);
self.setViewText('menu', MciCodeIds.Affils, user.properties[UserProps.Affiliations]);
self.setViewText('menu', MciCodeIds.Email, user.properties[UserProps.EmailAddress]);
self.setViewText('menu', MciCodeIds.Web, user.properties[UserProps.WebAddress]);
self.setViewText('menu', MciCodeIds.TermHeight, user.properties[UserProps.TermHeight].toString());
var themeView = self.getView(MciCodeIds.Theme);

View File

@ -5,6 +5,7 @@
const { MenuModule } = require('./menu_module.js');
const { getUserList } = require('./user.js');
const { Errors } = require('./enig_error.js');
const UserProps = require('./user_property.js');
// deps
const moment = require('moment');
@ -44,7 +45,7 @@ exports.getModule = class UserListModule extends MenuModule {
}
const fetchOpts = {
properties : [ 'real_name', 'location', 'affiliation', 'last_login_timestamp' ],
properties : [ UserProps.RealName, UserProps.Location, UserProps.Affiliations, UserProps.LastLoginTs ],
propsCamelCase : true, // e.g. real_name -> realName
};
getUserList(fetchOpts, (err, userList) => {

View File

@ -8,34 +8,44 @@ const StatLog = require('./stat_log.js');
const logger = require('./logger.js');
const Events = require('./events.js');
const Config = require('./config.js').get;
const {
Errors,
ErrorReasons
} = require('./enig_error.js');
const UserProps = require('./user_property.js');
// deps
const async = require('async');
const _ = require('lodash');
exports.userLogin = userLogin;
function userLogin(client, username, password, cb) {
client.user.authenticate(username, password, function authenticated(err) {
client.user.authenticate(username, password, err => {
const config = Config();
if(err) {
client.user.sessionFailedLoginAttempts = _.get(client.user, 'sessionFailedLoginAttempts', 0) + 1;
const disconnect = config.users.failedLogin.disconnect;
if(disconnect > 0 && client.user.sessionFailedLoginAttempts >= disconnect) {
err = Errors.BadLogin('To many failed login attempts', ErrorReasons.TooMany);
}
client.log.info( { username : username, error : err.message }, 'Failed login attempt');
// :TODO: if username exists, record failed login attempt to properties
// :TODO: check Config max failed logon attempts/etc. - set err.maxAttempts = true
return cb(err);
}
const user = client.user;
const user = client.user;
// Good login; reset any failed attempts
delete user.sessionFailedLoginAttempts;
//
// Ensure this user is not already logged in.
// Loop through active connections -- which includes the current --
// and check for matching user ID. If the count is > 1, disallow.
//
let existingClientConnection;
clientConnections.forEach(function connEntry(cc) {
if(cc.user !== user && cc.user.userId === user.userId) {
existingClientConnection = cc;
}
const existingClientConnection = clientConnections.find(cc => {
return user !== cc.user && // not current connection
user.userId === cc.user.userId; // ...but same user
});
if(existingClientConnection) {
@ -48,12 +58,10 @@ function userLogin(client, username, password, cb) {
'Already logged in'
);
const existingConnError = new Error('Already logged in as supplied user');
existingConnError.existingConn = true;
// :TODO: We should use EnigError & pass existing connection as second param
return cb(existingConnError);
return cb(Errors.BadLogin(
`User ${user.username} already logged in.`,
ErrorReasons.AlreadyLoggedIn
));
}
// update client logger with addition of username
@ -67,24 +75,24 @@ function userLogin(client, username, password, cb) {
client.log.info('Successful login');
// User's unique session identifier is the same as the connection itself
user.sessionId = client.session.uniqueId; // convienence
user.sessionId = client.session.uniqueId; // convenience
Events.emit(Events.getSystemEvents().UserLogin, { user } );
async.parallel(
[
function setTheme(callback) {
setClientTheme(client, user.properties.theme_id);
setClientTheme(client, user.properties[UserProps.ThemeId]);
return callback(null);
},
function updateSystemLoginCount(callback) {
return StatLog.incrementSystemStat('login_count', 1, callback);
return StatLog.incrementSystemStat('login_count', 1, callback); // :TODO: create system_property.js
},
function recordLastLogin(callback) {
return StatLog.setUserStat(user, 'last_login_timestamp', StatLog.now, callback);
return StatLog.setUserStat(user, UserProps.LastLoginTs, StatLog.now, callback);
},
function updateUserLoginCount(callback) {
return StatLog.incrementUserStat(user, 'login_count', 1, callback);
return StatLog.incrementUserStat(user, UserProps.LoginCount, 1, callback);
},
function recordLoginHistory(callback) {
const loginHistoryMax = Config().statLog.systemEvents.loginHistoryMax;

53
core/user_property.js Normal file
View File

@ -0,0 +1,53 @@
/* jslint node: true */
'use strict';
//
// Common user properties used throughout the system.
//
// This IS NOT a full list. For example, custom modules
// can utilize their own properties as well!
//
module.exports = {
PassPbkdf2Salt : 'pw_pbkdf2_salt',
PassPbkdf2Dk : 'pw_pbkdf2_dk',
AccountStatus : 'account_status', // See User.AccountStatus enum
RealName : 'real_name',
Sex : 'sex',
Birthdate : 'birthdate',
Location : 'location',
Affiliations : 'affiliation',
EmailAddress : 'email_address',
WebAddress : 'web_address',
TermHeight : 'term_height',
TermWidth : 'term_width',
ThemeId : 'theme_id',
AccountCreated : 'account_created',
LastLoginTs : 'last_login_timestamp',
LoginCount : 'login_count',
UserComment : 'user_comment', // NYI
DownloadQueue : 'dl_queue', // download_queue.js
FailedLoginAttempts : 'failed_login_attempts',
AccountLockedTs : 'account_locked_timestamp',
AccountLockedPrevStatus : 'account_locked_prev_status', // previous account status before lock out
EmailPwResetToken : 'email_password_reset_token',
EmailPwResetTokenTs : 'email_password_reset_token_ts',
FileAreaTag : 'file_area_tag',
FileBaseFilters : 'file_base_filters',
FileBaseFilterActiveUuid : 'file_base_filter_active_uuid',
FileBaseLastViewedId : 'user_file_base_last_viewed',
FileDlTotalCount : 'dl_total_count',
FileUlTotalCount : 'ul_total_count',
FileDlTotalBytes : 'dl_total_bytes',
FileUlTotalBytes : 'ul_total_bytes',
MessageConfTag : 'message_conf_tag',
MessageAreaTag : 'message_area_tag',
MessagePostCount : 'post_count',
};

View File

@ -10,6 +10,7 @@ const User = require('./user.js');
const userDb = require('./database.js').dbs.user;
const getISOTimestampString = require('./database.js').getISOTimestampString;
const Log = require('./logger.js').log;
const UserProps = require('./user_property.js');
// deps
const async = require('async');
@ -17,6 +18,7 @@ const crypto = require('crypto');
const fs = require('graceful-fs');
const url = require('url');
const querystring = require('querystring');
const _ = require('lodash');
const PW_RESET_EMAIL_TEXT_TEMPLATE_DEFAULT =
`%USERNAME%:
@ -57,7 +59,7 @@ class WebPasswordReset {
}
User.getUser(userId, (err, user) => {
if(err || !user.properties.email_address) {
if(err || !user.properties[UserProps.EmailAddress]) {
return callback(Errors.DoesNotExist('No email address associated with this user'));
}
@ -77,8 +79,8 @@ class WebPasswordReset {
token = token.toString('hex');
const newProperties = {
email_password_reset_token : token,
email_password_reset_token_ts : getISOTimestampString(),
[ UserProps.EmailPwResetToken ] : token,
[ UserProps.EmailPwResetTokenTs ] : getISOTimestampString(),
};
// we simply place the reset token in the user's properties
@ -103,13 +105,13 @@ class WebPasswordReset {
function buildAndSendEmail(user, textTemplate, htmlTemplate, callback) {
const sendMail = require('./email.js').sendMail;
const resetUrl = webServer.instance.buildUrl(`/reset_password?token=${user.properties.email_password_reset_token}`);
const resetUrl = webServer.instance.buildUrl(`/reset_password?token=${user.properties[UserProps.EmailPwResetToken]}`);
function replaceTokens(s) {
return s
.replace(/%BOARDNAME%/g, Config().general.boardName)
.replace(/%USERNAME%/g, user.username)
.replace(/%TOKEN%/g, user.properties.email_password_reset_token)
.replace(/%TOKEN%/g, user.properties[UserProps.EmailPwResetToken])
.replace(/%RESET_URL%/g, resetUrl)
;
}
@ -120,7 +122,7 @@ class WebPasswordReset {
}
const message = {
to : `${user.properties.display_name||user.username} <${user.properties.email_address}>`,
to : `${user.properties[UserProps.RealName]||user.username} <${user.properties[UserProps.EmailAddress]}>`,
// from will be filled in
subject : 'Forgot Password',
text : textTemplate,
@ -283,8 +285,15 @@ class WebPasswordReset {
}
// delete assoc properties - no need to wait for completion
user.removeProperty('email_password_reset_token');
user.removeProperty('email_password_reset_token_ts');
user.removeProperties([ UserProps.EmailPwResetToken, UserProps.EmailPwResetTokenTs ]);
if(true === _.get(config, 'users.unlockAtEmailPwReset')) {
Log.info(
{ username : user.username, userId : user.userId },
'Remove any lock on account due to password reset policy'
);
user.unlockAccount( () => { /* dummy */ } );
}
resp.writeHead(200);
return resp.end('Password changed successfully');

View File

@ -14,15 +14,17 @@
- [Creating Config Files]({{ site.baseurl }}{% link configuration/creating-config.md %})
- [SysOp Setup]({{ site.baseurl }}{% link configuration/sysop-setup.md %})
- [Editing hjson]({{ site.baseurl }}{% link configuration/editing-hjson.md %})
- [config.hjson]({{ site.baseurl }}{% link configuration/config-hjson.md %})
- [menu.hjson]({{ site.baseurl }}{% link configuration/menu-hjson.md %})
- [prompt.hjson]({{ site.baseurl }}{% link configuration/prompt-hjson.md %})
- [System Configuration]({{ site.baseurl }}{% link configuration/config-hjson.md %})
- [HJSON General]({{ site.baseurl }}{% link configuration/hjson.md %})
- [Menus]({{ site.baseurl }}{% link configuration/menu-hjson.md %})
- [Prompts]({{ site.baseurl }}{% link configuration/prompt-hjson.md %})
- [Directory Structure]({{ site.baseurl }}{% link configuration/directory-structure.md %})
- [Archivers]({{ site.baseurl }}{% link configuration/archivers.md %})
- [File Transfer Protocols]({{ site.baseurl }}{% link configuration/file-transfer-protocols.md %})
- [Email]({{ site.baseurl }}{% link configuration/email.md %})
- [Colour Codes]({{ site.baseurl }}{% link configuration/colour-codes.md %})
- [Access Condition System (ACS)]({{ site.baseurl }}{% link configuration/acs.md %})
- [Event Scheduler]({{ site.baseurl }}{% link configuration/event-scheduler.md %})
- Scheduled jobs
- File Base
@ -73,11 +75,13 @@
- [Rumorz]({{ site.baseurl }}{% link modding/rumorz.md %})
- [File Transfer Protocol Select]({{ site.baseurl }}{% link modding/file-transfer-protocol-select.md %})
- [Onelinerz]({{ site.baseurl }}{% link modding/onelinerz.md %})
- [Show Art]({{ site.baseurl }}{% link modding/show-art.md %})
- [Download Manager]({{ site.baseurl }}{% link modding/file-base-download-manager.md %})
- [Web Download Manager]({{ site.baseurl }}{% link modding/file-base-web-download-manager.md %})
- [Set Newscan Date]({{ site.baseurl }}{% link modding/set-newscan-date.md %})
- Administration
- [oputil]({{ site.baseurl }}{% link admin/oputil.md %})
- [Oputil]({{ site.baseurl }}{% link oputil/index.md %})
- Troubleshooting
- [Monitoring Logs]({{ site.baseurl }}{% link troubleshooting/monitoring-logs.md %})

View File

@ -27,7 +27,7 @@ Commands break up operations by groups:
| Command | Description |
|-----------|---------------|
| `user` | User management |
| `config` | System configuration and maintentance |
| `config` | System configuration and maintenance |
| `fb` | File base configuration and management |
| `mb` | Message base configuration and management |
@ -45,11 +45,12 @@ usage: optutil.js user <action> [<args>]
actions:
pw USERNAME PASSWORD set password to PASSWORD for USERNAME
rm USERNAME permanantely removes USERNAME user from system
rm USERNAME permanently removes USERNAME user from system
activate USERNAME sets USERNAME's status to active
deactivate USERNAME sets USERNAME's status to deactive
deactivate USERNAME sets USERNAME's status to inactive
disable USERNAME sets USERNAME's status to disabled
group USERNAME [+|-]GROUP adds (+) or removes (-) USERNAME from GROUP
lock USERNAME sets USERNAME's status to locked
group USERNAME [+|-]GROUP adds (+) or removes (-) user from GROUP
```
| Action | Description | Examples | Aliases |
@ -59,6 +60,7 @@ actions:
| `activate` | Activates user | `./oputil.js user activate joeuser` | N/A |
| `deactivate` | Deactivates user | `./oputil.js user deactivate joeuser` | N/A |
| `disable` | Disables user (user will not be able to login) | `./oputil.js user disable joeuser` | N/A |
| `lock` | Locks the user account (prevents logins) | `./oputil.js user lock joeuser` | N/A |
| `group` | Modifies users group membership | Add to group: `./oputil.js user group joeuser +derp`<br/>Remove from group: `./oputil.js user group joeuser -derp` | N/A |
## Configuration
@ -82,7 +84,7 @@ import-areas args:
| Action | Description | Examples |
|-----------|-------------------|---------------------------------------|
| `new` | Generates a new/initial configuration | `./oputil.js config new` (follow the prompts) |
| `import-areas` | Imports areas using a Fidonet style *.NA or AREAS.BBS formatted file | `./oputil.js config import-areas /some/path/l33tnet.na` |
| `import-areas` | Imports areas using a FidoNet style *.NA or AREAS.BBS formatted file | `./oputil.js config import-areas /some/path/l33tnet.na` |
When using the `import-areas` action, you will be prompted for any missing additional arguments described in "import-areas args".
@ -138,7 +140,7 @@ general information:
The `scan` action can (re)scan a file area for new entries as well as update (`--update`) existing entry records (description, etc.). When scanning, a valid area tag must be specified. Optionally, storage tag may also be supplied in order to scan a specific filesystem location using the `@the_storage_tag` syntax. If a [GLOB](http://man7.org/linux/man-pages/man7/glob.7.html) is supplied as the last argument, only file entries with filenames matching will be processed.
##### Examples
Performing a quick scan of a specific area's storage location ("retro_warez", "retro_warez_games) matching only *.zip extentions:
Performing a quick scan of a specific area's storage location ("retro_warez", "retro_warez_games) matching only *.zip extensions:
```
$ ./oputil.js fb scan --quick retro_warez@retro_warez_games *.zip`
```

View File

@ -1,5 +1,111 @@
---
layout: page
title: General
title: General Art Information
---
General art lives in the `art/general` directory. 'General' art is ANSI you want to stay consistent across themes, such as a welcome ANSI or a rotation of logoff ANSIs.
## General Art Information
One of the most basic elements of BBS customization is through it's artwork. ENiGMA½ supports a variety of ways to select, display, and manage art.
As a general rule, art files live in one of two places:
1. The `art/general` directory. This is where you place command non-themed art files.
2. Within a theme such as `art/themes/super_fancy_theme`.
### Menu Entries
While art can be displayed programmatically such as from a custom module, the most basic and common form is via `menu.hjson` entries. This usually falls into one of two forms: a "standard" entry where a single `art` spec is utilized or a entry for a custom module where multiple pieces are declared and used. The second style usually takes the form of a `config.art` block with two or more entries.
A menu entry has a few elements that control how art is choosen and displayed. First, the `art` *spec* tells teh system how to look for the art asset. Second, the `config` block can further control aspecs of lookup and display:
| Item | Description|
|------|------------|
| `font` | Sets the [SyncTERM](http://syncterm.bbsdev.net/) style font to use when displaying this art. If unset, the system will use the art's embedded [SAUCE](http://www.acid.org/info/sauce/sauce.htm) record if present or simply use the current font. See Fonts below. |
| `pause` | If set to `true`, pause after displaying. |
| `baudRate` | Set a [SyncTERM](http://syncterm.bbsdev.net/) style emulated baud rate when displaying this art. In other words, slow down the display. |
| `cls` | Clear the screen before display if set to `true`. |
| `random` | Set to `false` to explicitly disable random lookup. |
| `types` | An optional array of types (aka file extensions) to consider for lookup. For example : `[ '.ans', '.asc' ]` |
| `readSauce` | May be set to `false` if you need to explictly disable SAUCE support. |
#### Art Spec
It was mentioned that the `art` member is a *spec*. The value of a `art` member controls how the system looks for an asset. The following forms are supported:
* `FOO`: The system will look for `FOO.ANS`, `FOO.ASC`, `FOO.TXT`, etc. using the default search path. Unless otherwise specified if `FOO1.ANS`, `FOO2.ANS`, and so on exist, a random selection will be made.
* `FOO.ANS`: By specifying an extension, only that type will be searched for.
* `rel/path/to/BAR.ANS`: Only match a path (relative to the system's `art` directory).
* `/path/to/BAZ.ANS`: Exact path only.
ENiGMA½ uses a fallback system for art selection. When a menu entry calls for a piece of art, the following search is made:
1. If a direct or relative path is supplied, look there first.
2. In the users current theme directory.
3. In the system default theme directory.
4. In the `art/general` directory.
#### SyncTERM Style Fonts
ENiGMA½ can set a [SyncTERM](http://syncterm.bbsdev.net/) style font for art display. This is supported by many popular BBS terminals besides just SyncTERM and is common for displaying Amiga style fonts for example. The system will use the `font` specifier or look for a font declared in an artworks SAUCE record (unless `readSauce` is `false`).
The most common fonts are probably as follows:
* `cp437`
* `c64_upper`
* `c64_lower`
* `c128_upper`
* `c128_lower`
* `atari`
* `pot_noodle`
* `mo_soul`
* `microknight_plus`
* `topaz_plus`
* `microknight`
* `topaz`
Other fonts fonts also available:
* `cp1251`
* `koi8_r`
* `iso8859_2`
* `iso8859_4`
* `cp866`
* `iso8859_9`
* `haik8`
* `iso8859_8`
* `koi8_u`
* `iso8859_15`
* `iso8859_4`
* `koi8_r_b`
* `iso8859_4`
* `iso8859_5`
* `ARMSCII_8`
* `iso8859_15`
* `cp850`
* `cp850`
* `cp885`
* `cp1251`
* `iso8859_7`
* `koi8-r_c`
* `iso8859_4`
* `iso8859_1`
* `cp866`
* `cp437`
* `cp866`
* `cp885`
* `cp866_u`
* `iso8859_1`
* `cp1131`
See [this specification](https://github.com/protomouse/synchronet/blob/master/src/conio/cterm.txt) for more information.
#### SyncTERM Style Baud Rates
The `baudRate` member can set a [SyncTERM](http://syncterm.bbsdev.net/) style emulated baud rate. May be `300`, `600`, `1200`, `2400`, `4800`, `9600`, `19200`, `38400`, `57600`, `76800`, or `115200`. A value of `ulimited`, `off`, or `0` resets (disables) the rate. See [this specification](https://github.com/protomouse/synchronet/blob/master/src/conio/cterm.txt) for more information.
## Common Example
```hjson
fullLogoffSequenceRandomBoardAd: {
art: OTHRBBS
desc: Logging Off
next: logoff
config: {
baudRate: 57600
pause: true
cls: true
}
}
```

View File

@ -72,13 +72,14 @@ for a full listing. Many codes attempt to pay homage to Oblivion/2, iNiQUiTY, et
| `SP` | Total uploaded amount, system wide (formatted to appropriate bytes/megs/etc.) |
Some additional special case codes also exist:
| Code | Description |
|--------|--------------|
| `CF##` | Moves the cursor position forward _##_ characters |
| `CB##` | Moves the cursor position back _##_ characters |
| `CU##` | Moves the cursor position up _##_ characters |
| `CD##` | Moves the cursor position down _##_ characters |
| `XY` | A special code that may be utilized for placement identification when creating menus or to extend an otherwise empty space in an art file down the screen.
| `XY` | A special code that may be utilized for placement identification when creating menus or to extend an otherwise empty space in an art file down the screen. |
## Views
@ -104,7 +105,7 @@ see additional information.
## Properties & Theming
Predefined MCI codes and other Views can have properties set via `menu.hjson` and further *themed* via `theme.hjson`.
Predefined MCI codes and other Views can have properties set via `menu.hjson` and further *themed* via `theme.hjson`. See [Themes](themes.md) for more information on this subject.
### Common Properties

View File

@ -2,28 +2,131 @@
layout: page
title: Themes
---
# Creating Your Own
:warning: ***IMPORTANT!*** It is recommended you don't make any customisations to the included
`luciano_blocktronics' theme. Create your own and make changes to that instead:
## Themes
ENiGMA½ comes with an advanced theming system allowing system operators to highly customize the look and feel of their boards. A given installation can have as many themes as you like for your users to choose from.
## General Information
Themes live in `art/themes/`. Each theme (and thus it's *theme ID*) is a directory within the `themes` directory. The theme itself is simply a collection of art files, and a `theme.hjson` file that further defines layout, colors & formatting, etc. ENiGMA½ comes with a default theme by [Luciano Ayres](http://blocktronics.org/tag/luciano-ayres/) of [Blocktronics](http://blocktronics.org/) called Mystery Skull. This theme is in `art/themes/luciano_blocktronics`, and thus it's *theme ID* is `luciano_blocktronics`.
## Art
For information on art files, see [General Art Information](general.md). TL;DR: In general, to theme a piece of art, create a version of it in your themes directory.
:information_source: Remember that by default, the system will allow for randomly selecting art (in one of the directories mentioned above) by numbering it: `FOO1.ANS`, `FOO2.ANS`, etc.!
## Theme Sections
Themes are some important sections to be aware of:
| Config Item | Description |
|-------------|----------------------------------------------------------|
| `info` | This section describes the theme. |
| `customization` | The beef! |
### Info Block
The `info` configuration block describes the theme itself.
| Item | Required | Description |
|-------------|----------|----------------------------------------------------------|
| `name` | :+1: | Name of the theme. Be creative! |
| `author` | :+1: | Author of the theme/artwork. |
| `group` | :-1: | Group/affils of author. |
| `enabled` | :-1: | Boolean of enabled state. If set to `false`, this theme will not be available to your users. If a user currently has this theme selected, the system default will be selected for them at next login. |
### Customization Block
The `customization` block in is itself broken up into major parts:
| Item | Description |
|-------------|---------------------------------------------------|
| `defaults` | Default values to use when this theme is active. These values override system defaults, but can still be overridden themselves in specific areas of your theme. |
| `menus` | The bulk of what you theme in the system will be here. Any menu (that is, anything you find in `menu.hjson`) can be tweaked. |
| `prompts` | Similar to `menus`, this file themes prompts found in `prompts.hjson`. |
#### Defaults
| Item | Description |
|-------------|---------------------------------------------------|
| `passwordChar` | Character to display in password fields. Defaults to `*` |
| `dateFormat` | Sets the [moment.js](https://momentjs.com/docs/#/displaying/) style `short` and/or `long` format for dates. |
| `timeFormat` | Sets the [moment.js](https://momentjs.com/docs/#/displaying/) style `short` and/or `long` format for times. |
| `dateTimeFormat` | Sets the [moment.js](https://momentjs.com/docs/#/displaying/) style `short` and/or `long` format for date/time combinations. |
Example:
```hjson
defaults: {
dateTimeFormat: {
short: MMM Do h:mm a
}
}
```
#### Menus Block
Each *key* in the `menus` block matches up with a key found in your `menu.hjson`. For example, consider a `matrix` menu defined in `menu.hjson`. In addition to perhaps providing a `MATRIX.ANS` in your themes directory, you can also theme other parts of the menu via a `matrix` entry in `theme.hjson`.
Major areas to override/theme:
* `config`: Override and/or provide additional theme information over that found in the `menu.hjson`'s entry. Common entries here are for further overriding date/time formats, and custom range info formats (`<someFormName>InfoFormat<num>`). See Entry Formatting in [MCI Codes](mci.md) and Custom Range Info Formatting below.
* `mci`: Set per-MCI code properties such as `height`, `width`, text styles, etc. See [MCI Codes](mci.md) for a more information.
Two formats for `mci` blocks are allowed:
* Verbose where a form ID(s) are supplied.
* Shorthand if only a single/first form is needed.
Example: Verbose `mci` with form IDs:
```hjson
newUserFeedbackToSysOp: {
0: {
mci: {
TL1: { width: 19, textOverflow: "..." }
ET2: { width: 19, textOverflow: "..." }
ET3: { width: 19, textOverflow: "..." }
}
}
1: {
mci: {
MT1: { height: 14 }
}
}
}
```
Example: Shorthand `mci` format:
```hjson
matrix: {
mci: {
VM1: {
itemFormat: "|03{text}"
focusItemFormat: "|11{text!styleFirstLower}"
}
}
}
```
##### Custom Range Info Formatting
Many modules support "custom range" MCI items. These are MCI codes that are left to the user to define using a format object specific to the module. For example, consider the `msg_area_list` module: This module sets MCI codes 10+ (`%TL10`, `%TL11`, etc.) as "custom range". When theming you can place these MCI codes in your artwork then define the format in `theme.hjson`:
```hjson
messageAreaChangeCurrentArea: {
config: {
areaListInfoFormat10: "|15{name}|07: |03{desc}"
}
}
```
## Creating Your Own
:warning: ***IMPORTANT!*** It is recommended you don't make any customisations to the included `luciano_blocktronics' theme. Instead, create your own and make changes to that instead:
1. Copy `/art/themes/luciano_blocktronics` to `art/themes/your_board_theme`
2. Update the `info` block at the top of the theme.hjson file:
``` hjson
info: {
name: Awesome Theme
author: Cool Artist
group: Sick Group
enabled: true
name: Awesome Theme
author: Cool Artist
group: Sick Group
enabled: true // default
}
```
3. Specify it in the `defaults` section of `config.hjson`. The name supplied should match the name of the
directory you created in step 1:
3. If desired, you may make this the default system theme in `config.hjson` via `theme.default`. `theme.preLogin` may be set if you want this theme used for pre-authenticated users. Both of these values also accept `*` if you want the system to radomly pick.
``` hjson
defaults: {
theme: your_board_theme
}
theme: {
default: your_board_theme
preLogin: *
}
```
# General Theme Info

View File

@ -61,6 +61,7 @@ The following touch points exist in the system. Many more are planned:
* Message conferences and areas
* File base areas
* Menus within `menu.hjson`. See [menu.hjson](menu-hjson.md).
* Menus within `menu.hjson`. See [Menu HJSON](menu-hjson.md).
See the specific areas documentation for information on available ACS checks.

View File

@ -1,12 +1,14 @@
---
layout: page
title: config.hjson
title: System Configuration
---
## System Configuration
The main system configuration file, `config.hjson` both overrides defaults and provides additional configuration such as message areas. The default path is `/enigma-bbs-install-path/config/config.hjson` though you can override the `config.hjson` location with the `--config` parameter when invoking `main.js`. Values found in `core/config.js` may be overridden by simply providing the object members you wish replace.
See also [HJSON General Information](hjson.md) for more information on the HJSON format.
### Creating a Configuration
Your initial configuration skeleton can be created using the `oputil.js` command line utility. From your enigma-bbs root directory:
Your initial configuration skeleton should be created using the `oputil.js` command line utility. From your enigma-bbs root directory:
```
./oputil.js config new
```
@ -28,10 +30,21 @@ general: {
}
```
(Note the very slightly different syntax. **You can use standard JSON if you wish**)
(Note the very slightly [HJSON](hjson.md) different syntax. **You can use standard JSON if you wish!**)
While not everything that is available in your `config.hjson` file can be found defaulted in `core/config.js`, a lot is. [Poke around and see what you can find](https://github.com/NuSkooler/enigma-bbs/blob/master/core/config.js)!
### Configuration Sections
Below is a list of various configuration sections. There are many more, but this should get you started:
* [ACS](acs.md)
* [Archivers](archivers.md): Set up external archive utilities for handling things like ZIP, ARJ, RAR, and so on.
* [Email](email.md): System email support.
* [Event Scheduler](event-scheduler.md): Set up events as you see fit!
* [File Base](/docs/filebase/index.md)
* [File Transfer Protocols](file-transfer-protocols.md): Oldschool file transfer protocols such as X/Y/Z-Modem!
* [Message Areas](/docs/messageareas/configuring-a-message-area.md), [Networks](/docs/messageareas/message-networks.md), [NetMail](/docs/messageareas/netmail.md), etc.
### A Sample Configuration
Below is a **sample** `config.hjson` illustrating various (but certainly not all!) elements that can be configured / tweaked.

View File

@ -2,26 +2,13 @@
layout: page
title: Creating Initial Config Files
---
Configuration files in ENiGMA½ are simple UTF-8 encoded [HJSON](http://hjson.org/) files. HJSON is just
like JSON but simplified and much more resilient to human error.
Configuration files in ENiGMA½ are simple UTF-8 encoded [HJSON](http://hjson.org/) files. HJSON is just like JSON but simplified and much more resilient to human error.
## config.hjson
Your initial configuration skeleton can be created using the `oputil.js` command line utility. From your
enigma-bbs root directory:
```
## Initial Configuration
Your initial configuration skeleton can be created using the `oputil.js` command line utility. From your enigma-bbs root directory:
```bash
./oputil.js config new
```
You will be asked a series of questions to create an initial configuration.
You will be asked a series of questions to create an initial configuration, which will be saved to `/enigma-bbs-install-path/config/config.hjson`. This will also produce `config/<bbsName>-menu.hjson` and `config/<bbsName>-prompt.hjson` files (where `<bbsName>` is replaced by the name you provided in the steps above). See [Menu HJSON](menu-hjson.md) and [Prompt HJSON](prompt-hjson.md) for more information.
## menu.hjson and prompt.hjson
Create your own copy of `/config/menu.hjson` and `/config/prompt.hjson`, and specify it in the
`general` section of `config.hjson`:
````hjson
general: {
menuFile: my-menu.hjson
promptFile: my-prompt.hjson
}
````

View File

@ -2,16 +2,18 @@
layout: page
title: Email
---
ENiGMA½ uses email to send password reset information to users. For it to work, you need to provide valid SMTP
config in your [config.hjson]({{ site.baseurl }}{% link configuration/config-hjson.md %})
## Email Support
ENiGMA½ uses email to send password reset information to users. For it to work, you need to provide valid [Nodemailer](https://nodemailer.com/about/) compatible `email` block in your [config.hjson]({{ site.baseurl }}{% link configuration/config-hjson.md %}). Nodemailer supports SMTP in addition to many pre-defined services for ease of use. The `transport` block within `email` must be Nodemailer compatible.
## SMTP Services
Additional email support will come in the near future.
If you don't have an SMTP server to send from, [Sendgrid](https://sendgrid.com/) provide a reliable free
service.
## Services
## Example SMTP Configuration
If you don't have an SMTP server to send from, [Sendgrid](https://sendgrid.com/) and [Zoho](https://www.zoho.com/mail/) both provide reliable and free services.
## Example Configurations
Example 1 - SMTP:
```hjson
email: {
defaultFrom: sysop@bbs.awesome.com
@ -27,3 +29,21 @@ email: {
}
}
```
Example 2 - Zoho
```hjson
email: {
defaultFrom: sysop@bbs.awesome.com
transport: {
service: Zoho
auth: {
user: noreply@bbs.awesome.com
pass: yuspymypass
}
}
}
```
## Lockout Reset
If email is available on your system and you allow email-driven password resets, you may elect to allow unlocking accounts at the time of a password reset. This is controlled by the `users.unlockAtEmailPwReset` configuration option. If an account is locked due to too many failed login attempts, a user may reset their password to remedy the situation themselves.

View File

@ -0,0 +1,79 @@
---
layout: page
title: Event Scheduler
---
## Event Scheduler
The ENiGMA½ scheduler allows system operators to configure arbitrary events that can can fire based on date and/or time, or by watching for changes in a file. Events can kick off internal handlers, custom modules, or binaries & scripts.
## Scheduling Events
To create a scheduled event, create a new configuration block in `config.hjson` under `eventScheduler.events`.
Events can have the following members:
| Item | Required | Description |
|------|----------|-------------|
| `schedule` | :+1: | A [Later style](https://bunkat.github.io/later/parsers.html#text) parsable schedule string such as `at 4:00 am`, or `every 24 hours`. Can also be (or contain) an `@watch` clause. See **Schedules** below for details. |
| `action` | :+1: | Action to perform when the schedule is triggered. May be an `@method` or `@execute` spec. See **Actions** below. |
| `args` | :-1: | An array of arguments to pass along to the method or binary specified in `action`. |
### Schedules
As mentioned above, `schedule` may contain a [Later style](https://bunkat.github.io/later/parsers.html#text) parsable schedule string and/or an `@watch` clause.
`schedule` examples:
* `every 2 hours`
* `on the last day of the week`
* `after 12th hour`
An `@watch` clause monitors a specified file for changes and takes the following form: `@watch:<path>` where `<path>` is a fully qualified path.
:information_source: If you would like to have a schedule **and** watch a file for changes, place the `@watch` clause second and seperated with the word `or`. For example: `every 24 hours or @watch:/path/to/somefile.txt`.
### Actions
Events can kick off actions by calling a method (function) provided by the system or custom module in addition to executing arbritary binaries or scripts.
#### Methods
An action with a `@method` can take the following forms:
* `@method:/full/path/to/module.js:methodName`: Executes `methodName` at `/full/path/to/module.js`.
* `@method:rel/path/to/module.js:methodName`: Executes `methodName` using the *relative* path `rel/path/to/module.js`. Paths for `@method` are relative to the ENiGMA½ installation directory.
Methods are passed any supplied `args` in the order they are provided.
##### Method Signature
To create your own method, simply `export` a method with the following signature: `(args, callback)`. Methods are executed asynchronously.
Example:
```javascript
// my_custom_mod.js
exports.myCustomMethod = (args, cb) => {
console.log(`Hello, ${args[0]}!`);
return cb(null);
}
```
#### Executables
When using the `@execute` action, a binary or script can be executed. A full path or just the binary name is acceptable. If using the form without a path, the binary much be in ENiGMA½'s `PATH`.
Examples:
* `@execute:/usr/bin/foo`
* `@execute:foo`
Just like with methods, any supplied `args` will be passed along.
## Example Entries
Post a message to supplied networks every Monday night using the message post mod (see modding):
```hjson
eventScheduler: {
events: {
enigmaAdToNetworks: {
schedule: at 10:35 pm on Mon
action: @method:mods/message_post_evt/message_post_evt.js:messagePostEvent
args: [
"fsx_bot"
"/home/enigma-bbs/ad.asc"
]
}
}
}
```

View File

@ -0,0 +1,69 @@
---
layout: page
title: HJSON General Information
---
## JSON for Humans!
HJSON is the configuration file format used by ENiGMA½ for [System Configuration](config-hjson.md), [Menus](menu-hjson.md), [Prompts](prompt-hjson.md), etc. [HJSON](https://hjson.org/) is is [JSON](https://json.org/) for humans!
For those completely unfamiliar, JSON stands for JavaScript Object Notation. But don't let that scare you! JSON is simply a text file format with a bit of structure ― kind of like a fancier INI file. HJSON on the other hand as mentioned previously, is JSON for humans. That is, it has the following features and more:
* More resilient to syntax errors such as missing a comma
* Strings generally do not need to be quoted. Multi-line strings are also supported!
* Comments are supported (JSON doesn't allow this!): `#`, `//` and `/* ... */` style comments are allowed.
* Keys never need to be quoted
* ...much more! See [the official HJSON website](https://hjson.org/).
## Terminology
Through the documentation, some terms regarding HJSON and configuration files will be used:
* `config.hjson`: Refers to `/path/to/enigma-bbs/config/config.hjson`. See [System Configuration](config-hjson.md).
* `menu.hjson`: Refers to `/path/to/enigma-bbs/config/<yourBBSName>-menu.hjson`. See [Menus](menu-hjson.md).
* `prompt.hjson`: Refers to `/path/to/enigma-bbs/config/<yourBBSName>-prompt.hjson`. See [Prompts](prompt-hjson.md).
* Configuration *key*: Elements in HJSON are name-value pairs where the name is the *key*. For example, provided `foo: bar`, `foo` is the key.
* Configuration *section* or *block* (also commonly called an "Object" in code): This is referring to a section in a HJSON file that starts with a *key*. For example:
```hjson
someSection: {
foo: bar
}
```
Note that `someSection` is the configuration *section* (or *block*) and `foo: bar` is within it.
## Editing HJSON
HJSON is a text file format, and ENiGMA½ configuration files **should always be saved as UTF-8**.
It is **highly** recommended to use a text editor that has HJSON support. A few (but not all!) examples include:
* Sublime Text 3 via the `sublime-hjson` package.
* Visual Studio code via the `vscode-hjson` plugin.
* Notepad++ via the `npp-hjson` plugin.
See https://hjson.org/users.html for more more editors & plugins.
### Hot-Reload A.K.A. Live Editing
ENiGMA½'s configuration, menu, and theme files can edited while your BBS is running. When a file is saved, it is hot-reloaded into the running system. If users are currently connected and you change a menu for example, the next reload of that menu will show the changes.
### CaSe SeNsiTiVE
Configuration keys are **case sensitive**. That means if a configuration key is `boardName` for example, `boardname`, or `BOARDNAME` **will not work**.
### Escaping
Some values need escaped. This is especially important to remember on Windows machines where file paths contain backslashes (`\`). To specify a path to `C:\foo\bar\baz.exe` for example, an entry may look like this in your configuration file:
```hjson
something: {
path: "C:\\foo\\bar\\baz.exe" // note the extra \'s!
}
```
## Tips & Tricks
### JSON Compatibility
Remember that standard JSON is fully compatible with HJSON. If you are more comfortable with JSON (or have an editor that works with JSON that you prefer) simply convert your config file(s) to JSON and use that instead!
HJSON can be converted to JSON with the `hjson` CLI:
```bash
cd /path/to/enigma-bbs
cp ./config/config.hjson ./config/config.hjson.backup
./node_modules/hjson/bin/hjson ./config/config.hjson.backup -j > ./config/config.hjson
```
You can always convert back to HJSON by omitting `-j` in the command above.
### oputil
You can easily dump out your current configuration in a pretty-printed style using oputil: ```./oputil.js config cat```

View File

@ -1,20 +1,11 @@
---
layout: page
title: menu.hjson
title: Menus
---
:warning: ***IMPORTANT!*** Before making any customisations, create your own copy of `/config/menu.hjson`, and specify it in the `general` section of `config.hjson`:
## Menus
The core of a ENiGMA½ based BBS is `menu.hjson`. Note that when `menu.hjson` is referenced, we're actually talking about `config/yourboardname-menu.hjson` or similar. This file determines the menus (or screens) a user can see, the order they come in and how they interact with each other, ACS configuration, etc. Like all configuration within ENiGMA½, menu configuration is done in [HJSON](https://hjson.org/) format. See [HJSON General Information](hjson.md) for more information.
````hjson
general: {
menuFile: yourboardname.hjson
}
````
This document and others will refer to `menu.hjson`. This should be seen as an alias to `yourboardname.hjson`
## The Basics
Like all configuration within ENiGMA½, menu configuration is done in [HJSON](https://hjson.org/) format.
Entries in `menu.hjson` are objects or _sections_ defining a menu. A menu in this sense is something the user can see or visit. Examples include but are not limited to:
Entries in `menu.hjson` are often referred to as *blocks* or *sections*. Each entry defines a menu. A menu in this sense is something the user can see or visit. Examples include but are not limited to:
* Classical Main, Messages, and File menus
* Art file display
@ -23,21 +14,47 @@ Entries in `menu.hjson` are objects or _sections_ defining a menu. A menu in thi
Menu entries live under the `menus` section of `menu.hjson`. The *key* for a menu is it's name that can be referenced by other menus and areas of the system.
## Common Menu Entry Members
* `desc`: A friendly description that can be found in places such as "Who's Online" or the `%MD` MCI code.
* `art`: An art file specification.
* `next`: Specifies the next menu to go to next. Can be explicit or an array of possibilites dependent on ACS. See **Flow Control** in the **ACS Checks** section below.
* `prompt`: Specifies a prompt, by name, to use along with this menu.
* `form`: Defines one or more forms available on this menu.
* `submit`: Defines a submit handler when using `prompt`.
* `config`: May contain any of the following standard configuration members in addition to per-module defined types (see appropriate module for more information):
* `cls`: If `true` the screen will be cleared before showing this menu.
* `pause`: If `true` a pause will occur after showing this menu. Useful for simple menus such as displaying art or status screens.
* `nextTimeout`: Sets the number of **milliseconds** before the system will automatically advanced to the `next` menu.
* `baudRate`: Sets the SyncTERM style emulated baud rate. May be `300`, `600`, `1200`, `2400`, `4800`, `9600`, `19200`, `38400`, `57600`, `76800`, or `115200`. A value of `ulimited`, `off`, or `0` resets (disables) the rate. See [this specification](https://github.com/protomouse/synchronet/blob/master/src/conio/cterm.txt) for more information.
* `font`: Sets the SyncTERM style font. May be one of the following: `cp437`, `cp1251`, `koi8_r`, `iso8859_2`, `iso8859_4`, `cp866`, `iso8859_9`, `haik8`, `iso8859_8`, `koi8_u`, `iso8859_15`, `iso8859_4`, `koi8_r_b`, `iso8859_4`, `iso8859_5`, `ARMSCII_8`, `iso8859_15`, `cp850`, `cp850`, `cp885`, `cp1251`, `iso8859_7`, `koi8-r_c`, `iso8859_4`, `iso8859_1`, `cp866`, `cp437`, `cp866`, `cp885`, `cp866_u`, `iso8859_1`, `cp1131`, `c64_upper`, `c64_lower`, `c128_upper`, `c128_lower`, `atari`, `pot_noodle`, `mo_soul`, `microknight_plus`, `topaz_plus`, `microknight`, `topaz`. See [this specification](https://github.com/protomouse/synchronet/blob/master/src/conio/cterm.txt) for more information.
Below is a table of **common** menu entry members. These members apply to most entries, though entries that are backed by a specialized module (ie: `module: bbs_list`) may differ. See documentation for the module in question for particulars.
| Item | Description |
|--------|--------------|
| `desc` | A friendly description that can be found in places such as "Who's Online" or wherever the `%MD` MCI code is used. |
| `art` | An art file *spec*. See [General Art Information](/docs/art/general.md). |
| `next` | Specifies the next menu entry to go to next. Can be explicit or an array of possibilities dependent on ACS. See **Flow Control** in the **ACS Checks** section below. If `next` is not supplied, the next menu is this menus parent. |
| `prompt` | Specifies a prompt, by name, to use along with this menu. Prompts are configured in `prompt.hjson`. |
| `submit` | Defines a submit handler when using `prompt`.
| `form` | An object defining one or more *forms* available on this menu. |
| `module` | Sets the module name to use for this menu. |
| `config` | An object containing additional configuration. See **Config Block** below. |
### Config Block
The `config` block for a menu entry can contain common members as well as a per-module (when `module` is used) settings.
| Item | Description |
|------|-------------|
| `cls` | If `true` the screen will be cleared before showing this menu. |
| `pause` | If `true` a pause will occur after showing this menu. Useful for simple menus such as displaying art or status screens. |
| `nextTimeout` | Sets the number of **milliseconds** before the system will automatically advanced to the `next` menu. |
| `baudRate` | See baud rate information in [General Art Information](/docs/art/general.md). |
| `font` | Sets a SyncTERM style font to use when displaying this menus `art`. See font listing in [General Art Information](/docs/art/general.md). |
| `menuFlags` | An array of menu flag(s) controlling menu behavior. See **Menu Flags** below.
#### Menu Flags
The `menuFlags` field of a `config` block can change default behavior of a particular menu.
| Flag | Description |
|------|-------------|
| `noHistory` | Prevents the menu from remaining in the menu stack / history. When this flag is set, when the **next** menu falls back, this menu will be skipped and the previous menu again displayed instead. Example: menuA -> menuB(noHistory) -> menuC: Exiting menuC returns the user to menuA. |
| `popParent` | When *this* menu is exited, fall back beyond the parent as well. Often used in combination with `noHistory`. |
| `forwardArgs` | If set, when the next menu is entered, forward any `extraArgs` arguments to *this* menu on to it. |
## Forms
TODO
ENiGMA½ uses a concept of *forms* in menus. A form is a collection of associated *views*. Consider a New User Application using the `nua` module: The default implementation utilizes a single form with multiple EditTextView views, a submit button, etc. Forms are identified by number starting with `0`. A given menu may have mutiple forms (often associated with different states or screens within the menu).
Menus may also support more than one layout type by using a *MCI key*. A MCI key is a alpha-numerically sorted key made from 1:n MCI codes. This lets the system choose the appropriate set of form(s) based on theme or random art. An example of this may be a matrix menu: Perhaps one style of your matrix uses a vertical light bar (`VM` key) while another uses a horizontal (`HM` key). The system can discover the correct form to use by matching MCI codes found in the art to that of the available forms defined in `menu.hjson`.
For more information on views and associated MCI codes, see [MCI Codes](/docs/art/mci.md).
## Submit Handlers
TODO
@ -49,67 +66,69 @@ Let's look a couple basic menu entries:
telnetConnected: {
art: CONNECT
next: matrix
options: { nextTimeout: 1500 }
config: { nextTimeout: 1500 }
}
```
The above entry `telnetConnected` is set as the Telnet server's first menu entry (set by `firstMenu` in the Telnet server's config).
An art pattern of `CONNECT` is set telling the system to look for `CONNECT<n>.*` where `<n>` represents a optional integer in art files to cause randomness, e.g. `CONNECT1.ANS`, `CONNECT2.ANS`, and so on. If desired, you can also be explicit by supplying a full filename with an extention such as `CONNECT.ANS`.
The entry `next` sets up the next menu, by name, in the stack (`matrix`) that we'll go to after `telnetConnected`.
Finally, an `options` object may contain various common options for menus. In this case, `nextTimeout` tells the system to proceed to the `next` entry automatically after 1500ms.
The above entry `telnetConnected` is set as the Telnet server's first menu entry (set by `firstMenu` in the Telnet server's config). The entry sets up a few things:
* A `art` spec of `CONNECT`. (See [General Art Information](/docs/art/general.md)).
* A `next` entry up the next menu, by name, in the stack (`matrix`) that we'll go to after `telnetConnected`.
* An `config` block containing a single `nextTimeout` field telling the system to proceed to the `next` (`matrix`) entry automatically after 1500ms.
Now let's look at `matrix`, the `next` entry from `telnetConnected`:
```hjson
matrix: {
art: matrix
art: MATRIX
desc: Login Matrix
form: {
0: {
VM: {
mci: {
VM1: {
submit: true
focus: true
items: [ "login", "apply", "log off" ]
argName: matrixSubmit
0: {
//
// Here we have a MCI key of "VM". In this case we could
// omit this level since no other keys are present.
//
VM: {
mci: {
VM1: {
submit: true
focus: true
items: [ "login", "apply", "log off" ]
argName: matrixSubmit
}
}
submit: {
*: [
{
value: { matrixSubmit: 0 }
action: @menu:login
}
{
value: { matrixSubmit: 1 },
action: @menu:newUserApplication
}
{
value: { matrixSubmit: 2 },
action: @menu:logoff
}
]
}
}
//
// If we wanted, we could declare a "HM" MCI key block here.
// This would allow a horizontal matrix style when the matrix art
// loaded contained a %HM code.
//
}
submit: {
*: [
{
value: { matrixSubmit: 0 }
action: @menu:login
}
{
value: { matrixSubmit: 1 },
action: @menu:newUserApplication
}
{
value: { matrixSubmit: 2 },
action: @menu:logoff
}
]
}
}
}
}
}
```
In the above entry, you'll notice `form`. This defines a form(s) object. In this case, a single form
by ID of `0`. The system is then told to use a block only when the resulting art provides a `VM`
(*VerticalMenuView*) MCI entry. `VM1` is then setup to `submit` and start focused via `focus: true`
as well as have some menu entries ("login", "apply", ...) defined. We provide an `argName` for this
action as `matrixSubmit`.
In the above entry, you'll notice `form`. This defines a form(s) object. In this case, a single form by ID of `0`. The system is then told to use a block only when the resulting art provides a `VM` (*VerticalMenuView*) MCI entry. Some other bits about the form:
The `submit` object tells the system to attempt to apply provided match entries from any view ID (`*`).
Upon submit, the first match will be executed. For example, if the user selects "login", the first entry
with a value of `{ matrixSubmit: 0 }` will match causing `action` of `@menu:login` to be executed (go
to `login` menu).
* `VM1` is then setup to `submit` and start focused via `focus: true` as well as have some menu entries ("login", "apply", ...) defined. We provide an `argName` of `matrixSubmit` for this element view.
* The `submit` object tells the system to attempt to apply provided match entries from any view ID (`*`).
* Upon submit, the first match will be executed. For example, if the user selects "login", the first entry with a value of `{ matrixSubmit: 0 }` will match (due to 0 being the first index in the list and `matrixSubmit` being the arg name in question) causing `action` of `@menu:login` to be executed (go to `login` menu).
## ACS Checks
Menu modules can check user ACS in order to restrict areas and perform flow control. See [ACS](acs.md) for available ACS syntax.
@ -141,4 +160,23 @@ login: {
}
]
}
```
```
### Art Asset Selection
Another area in which you can apply ACS in a menu is art asset specs.
```hjson
someMenu: {
desc: Neato Dorito
art: [
{
acs: GM[couriers]
art: COURIERINFO
}
{
// show ie: EVERYONEELSE.ANS to everyone else
art: EVERYONEELSE
}
]
}
```

View File

@ -3,4 +3,6 @@ layout: page
title: prompt.hjson
---
:zap: This page is to describe general information the `prompt.hjson` file. It
needs fleshing out, please submit a PR if you'd like to help!
needs fleshing out, please submit a PR if you'd like to help!
See [HJSON General Information](hjson.md) for more information.

View File

@ -2,5 +2,4 @@
layout: page
title: SysOp Setup
---
SySop privileges will be granted to the first user to log into a fresh ENiGMA½ installation.
SySop privileges will be granted to the first user to log into a fresh ENiGMA½ installation. +ops belong to the `sysop` user group by default.

View File

@ -2,8 +2,8 @@
layout: page
title: ACS
---
If no `acs` block is supplied in a file area definition, the following defaults apply to an area:
## File Base ACS
[ACS Codes](/docs/configuration/acs.md) may be used to control acess to File Base areas by specifying an `acs` string in a file area's definition. If no `acs` is supplied in a file area definition, the following defaults apply to an area:
* `read` (list, download, etc.): `GM[users]`
* `write` (upload): `GM[sysops]`

View File

@ -19,16 +19,21 @@ are OK) for Windows users. Note that you **should only need the Visual C++ compo
* [git](https://git-scm.com/downloads) to check out the ENiGMA source code.
## Node.js
If you're new to Node.js and/or do not care about Node itself and just want to get ENiGMA½ running
these steps should get you going on most \*nix type environments:
### With NVM
Node Version Manager (NVM) is an excellent way to install and manage Node.js versions on most UNIX-like environments. [Get the latest version here](https://github.com/creationix/nvm). The install should look something like this:
```bash
curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.33.0/install.sh | bash
nvm install 6
nvm use 6
curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.33.11/install.sh | bash
```
If the above completed without errors, you should now have `nvm`, `node`, and `npm` installed and in your environment.
Next, install Node.js with NVM:
```bash
nvm install 10
nvm use 10
nvm alias default 10
```
If the above steps completed without errors, you should now have `nvm`, `node`, and `npm` installed and in your environment.
For Windows nvm-like systems exist ([nvm-windows](https://github.com/coreybutler/nvm-windows), ...) or [just download the installer](https://nodejs.org/en/download/).
@ -41,31 +46,25 @@ git clone https://github.com/NuSkooler/enigma-bbs.git
## Install Node Packages
```bash
cd enigma-bbs
npm install
npm install # yarn also works
```
## Other Recommended Packages
ENiGMA BBS makes use of a few packages for archive and legacy protocol support. They're not pre-requisites for running ENiGMA, but without them you'll miss certain functionality. Once installed, they should be made available on your system path.
ENiGMA BBS makes use of a few packages for unarchiving and modem support. They're not pre-requisites for
running ENiGMA, but without them you'll miss certain functionality. Once installed, they should be made
available on your system path.
| Package | Description | Debian/Ubuntu Package (APT/DEP) | Red Hat Package (YUM/RPM) | Windows Package |
| Package | Description | Debian/Ubuntu Package (APT/DEP) | Red Hat Package (YUM/RPM) | Windows Package |
|------------|-----------------------------------|--------------------------------------------|---------------------------------------------------|------------------------------------------------------------------|
| arj | Unpacking arj archives | `arj` | n/a, binaries [here](http://arj.sourceforge.net/) | [ARJ](http://arj.sourceforge.net/) |
| 7zip | Unpacking zip, rar, archives | `p7zip-full` | `p7zip-full` | [7-zip](http://www.7-zip.org/) |
| lha | Unpacking lha archives | `lhasa` | n/a, source [here](http://www2m.biglobe.ne.jp/~dolphin/lha/lha.htm) | Unknown |
| Rar | Unpacking rar archives | `unrar` | n/a, binaries [here](https://www.rarlab.com/download.htm) | Unknown |
| lrzsz | sz/rz: X/Y/Z modem support | `lrzsz` | `lrzsz` | Unknown |
| sexyz | SexyZ modem support | [sexyz](https://l33t.codes/outgoing/sexyz) | [sexyz](https://l33t.codes/outgoing/sexyz) | Available with [Synchronet](http://wiki.synchro.net/install:win) |
| lrzsz | sz/rz: X/Y/Z protocol support | `lrzsz` | `lrzsz` | Unknown |
| sexyz | SexyZ protocol support | [sexyz](https://l33t.codes/outgoing/sexyz) | [sexyz](https://l33t.codes/outgoing/sexyz) | Available with [Synchronet](http://wiki.synchro.net/install:win) |
| exiftool | [ExifTool](https://www.sno.phy.queensu.ca/~phil/exiftool/) | libimage-exiftool-perl | perl-Image-ExifTool | Unknown
| xdms | Unpack/view Amiga DMS | [xdms](http://manpages.ubuntu.com/manpages/trusty/man1/xdms.1.html) | xdms | Unknown
## Config Files
You'll need a basic configuration to get started. The main system configuration is handled via
`config/config.hjson`. This is an [HJSON](http://hjson.org/) file (compiliant JSON is also OK).
See [Configuration](../configuration/) for more information.
You'll need a basic configuration to get started. The main system configuration is handled via `config/config.hjson`. This is an [HJSON](http://hjson.org/) file (compiliant JSON is also OK). See [Configuration](../configuration/) for more information.
Use `oputil.js` to generate your **initial** configuration:

View File

@ -2,10 +2,13 @@
layout: page
title: OS & Hardware Specific Information
---
There are multiple ways of installing ENiGMA BBS, depending on your level of experience and desire to do
things manually versus have it automated for you.
There are multiple ways of installing ENiGMA BBS, depending on your level of experience and desire to do things manually versus have it automated for you.
| Method | Notes |
|----------------------------------------|---------------------------------------------------------------------------------------------|
| [Raspberry Pi](rpi) | All Raspberry Pi models work great with ENiGMA½! |
| [Windows](windows) | Compatible with all Windows Operating Systems |
In general, please see [Installation Methods](installation-methods.md) and [Install Script](install-script.md).
Below are some special cases:
| Method | Notes |
|--------|-------|
| [Raspberry Pi](rpi.md) | All Raspberry Pi models work great with ENiGMA½! |
| [Windows](windows.md) | Compatible with all Windows Operating Systems |

View File

@ -36,11 +36,13 @@ ENiGMA½ will run on both 32bit and 64bit Windows. If you want to run 16bit door
*Add 7zip to your path so `7z` can be called from the console
1. Right click `This PC` and Select `Properties`
2. Go to the `Advanced` Tab and click on `Enviromental Varibles`
3. Select `Path` under `System Varibles` and click `Edit`
2. Go to the `Advanced` Tab and click on `Environment Variables`
3. Select `Path` under `System Variables` and click `Edit`
4. Click `New` and paste the path to 7zip
5. Close your console window and reopen. You can type `7z` to make sure it's working.
(Please see [Archivers](/docs/archivers.md) for additional archive utilities!)
3. Install [Git](https://git-scm.com/downloads) and optionally [TortoiseGit](https://tortoisegit.org/download/).
4. Clone ENiGMA½ - browse to the directory you want and run

View File

@ -4,7 +4,7 @@ title: BSO Import / Export
---
The scanner/tosser module `ftn_bso` provides **B**inkley **S**tyle **O**utbound (BSO) import/toss and scan/export of messages EchoMail and NetMail messages. Configuration is supplied in `config.hjson` under `scannerTossers.ftn_bso`.
:information_source: ENiGMA½'s `ftn_bso` module is not a mailer and **makes no attempts** to perfrom packet transport! An external utility such as Binkd is required for this!
:information_source: ENiGMA½'s `ftn_bso` module is not a mailer and **makes no attempts** to perfrom packet transport! An external [mailer](http://www.filegate.net/bbsmailers.htm) such as [Binkd](https://github.com/pgul/binkd) is required for this!
Let's look at some of the basic configuration:
@ -23,7 +23,7 @@ Schedules can be defined for importing and exporting via `import` and `export` u
* `@immediate`: A message will be immediately exported if this trigger is defined in a schedule. Only used for `export`.
* `@watch:/path/to/file`: This trigger watches the path specified for changes and will trigger an import or export when such events occur. Only used for `import`.
* Free form text can be things like `at 5:00 pm` or `every 2 hours`.
* Free form [Later style](https://bunkat.github.io/later/parsers.html#text) text — can be things like `at 5:00 pm` or `every 2 hours`.
See [Later text parsing documentation](http://bunkat.github.io/later/parsers.html#text) for more information.
@ -45,14 +45,14 @@ See [Later text parsing documentation](http://bunkat.github.io/later/parsers.htm
## Nodes
The `nodes` section defines how to export messages for one or more uplinks.
A node entry starts with a FTN style address (up to 5D) **as a key** in `config.hjson`. This key may contain wildcard(s) for net/zone/node/point/domain.
A node entry starts with a [FTN address](http://ftsc.org/docs/old/fsp-1028.001) (up to 5D) **as a key** in `config.hjson`. This key may contain wildcard(s) for net/zone/node/point/domain.
| Config Item | Required | Description |
|------------------|----------|---------------------------------------------------------------------------------|
| `packetType` | :-1: | `2`, `2.2`, or `2+`. Defaults to `2+` for modern mailer compatiability |
| `packetPassword` | :-1: | Password for the packet |
| `encoding` | :-1: | Encoding to use for message bodies; Defaults to `utf-8` |
| `archiveType` | :-1: | Specifies the archive type for ArcMail bundles. Must be a valid archiver name such as `zip` (See archiver configuration) |
| `packetType` | :-1: | `2`, `2.2`, or `2+`. Defaults to `2+` for modern mailer compatiability. |
| `packetPassword` | :-1: | Optional password for the packet |
| `encoding` | :-1: | Encoding to use for message bodies; Defaults to `utf-8`. |
| `archiveType` | :-1: | Specifies the archive type (by extension) for ArcMail bundles. This should be `zip` for most setups. Other valid examples include `arc`, `arj`, `lhz`, `pak`, `sqz`, or `zoo`. See docs on archiver configuration for more information. |
**Example**:
```hjson
@ -60,9 +60,9 @@ A node entry starts with a FTN style address (up to 5D) **as a key** in `config.
scannerTossers: {
ftn_bso: {
nodes: {
"21:*": {
"21:*": { // wildcard address
packetType: 2+
packetPassword: mypass
packetPassword: D@TP4SS
encoding: cp437
archiveType: zip
}
@ -118,4 +118,36 @@ scannerTossers: {
}
}
}
```
```
## Binkd
Since Binkd is a very common mailer, a few tips on integrating it with ENiGMA½:
### Scheduling Polls
Binkd does not have it's own scheduler. Instead, you'll need to set up an Event Scheduler entry or perhaps a cron job:
First, create a script that runs through all of your uplinks. For example:
```bash
#!/bin/bash
UPLINKS=("21:1/100@fsxnet" "80:774/1@retronet" "10:101/0@araknet")
for uplink in "${UPLINKS[@]}"
do
/usr/local/sbin/binkd -p -P $uplink /home/enigma/xibalba/misc/binkd_xibalba.conf
done
```
Now, create an Event Scheuler entry in your `config.hjson`. As an example:
```hjson
eventScheduler: {
events: {
pollWithBink: {
// execute the script above very 1 hours
schedule: every 1 hours
action: @execute:/path/to/poll_bink.sh
}
}
}
```
## Additional Resources
* [Blog entry on setting up ENiGMA + Binkd on CentOS7](https://l33t.codes/enigma-12-binkd-on-centos-7/). Note that this references an **older version**, so be wary of the `config.hjson` refernces!

View File

@ -0,0 +1,23 @@
---
layout: page
title: File Base Download Manager
---
## File Base Download Manager Module
The `file_base_download_manager` module provides a download queue manager for "legacy" (X/Y/Z-Modem, etc.) downloads. Web (HTTP/HTTPS) download functionality can be optionally available when the web content server is enabled.
## Configuration
### Configuration Block
Available `config` block entries:
* `webDlExpireTimeFormat`: Sets the moment.js style format for web download expiration date/time.
* `fileTransferProtocolSelection`: Overrides the default `fileTransferProtocolSelection` target for a protocol selection menu.
* `emptyQueueMenu`: Overrides the default `fileBaseDownloadManagerEmptyQueue` target for menu to show when the users D/L queue is empty.
### Theming
The following `itemFormat` object is provided to MCI 1 (ie: `%VM1`) and MCI 10+ custom fields:
* `fileId`: File ID.
* `areaTag`: Area tag.
* `fileName`: Entry filename.
* `path`: Full file path.
* `byteSize`: Size in bytes of file.
* `webDlLink`: Web download link including [VTX style ANSI ESC sequences](https://raw.githubusercontent.com/codewar65/VTX_ClientServer/master/vtx.txt).
* `webDlExpire`: Expiration date/time for this link. Formatted using `webDlExpireTimeFormat`.

View File

@ -0,0 +1,26 @@
---
layout: page
title: File Base Web Download Manager
---
## File Base Web Download Manager Module
The `file_base_web_download_manager` module provides a download queue manager for web (HTTP/HTTPS) based downloads. This module relies on having the web server enabled at a minimum.
Web downloads can be a convienent way for users to download larger (100+ MiB) files where legacy protocols often have trouble. Additionally, batch downloads can be streamed to users in a single zip archive.
## Configuration
### Configuration Block
Available `config` block entries:
* `webDlExpireTimeFormat`: Sets the moment.js style format for web download expiration date/time.
* `emptyQueueMenu`: Overrides the default `fileBaseDownloadManagerEmptyQueue` target for menu to show when the users D/L queue is empty.
### Theming
The following `itemFormat` object is provided to MCI 1 (ie: `%VM1`) and custom range MCI 10+ custom fields:
* `fileId`: File ID.
* `areaTag`: Area tag.
* `fileName`: Entry filename.
* `path`: Full file path.
* `byteSize`: Size in bytes of file.
* `webDlLinkRaw`: Web download link.
* `webDlLink`: Web download link including [VTX style ANSI ESC sequences](https://raw.githubusercontent.com/codewar65/VTX_ClientServer/master/vtx.txt).
* `webDlExpire`: Expiration date/time for this link. Formatted using `webDlExpireTimeFormat`.

View File

@ -14,4 +14,4 @@ The following `itemFormat` object is provided to MCI 1 (ie: `%VM1`):
The following additional MCIs are updated as the user changes selections in the main list:
* MCI 2 (ie: `%TL2` or `%M%2`) is updated with the area description.
* MCI 10+ (ie `%TL10`...) are custom ranges updated with the same information available above in `itemFormat`.
* MCI 10+ (ie `%TL10`...) are custom ranges updated with the same information available above in `itemFormat`. Use `areaListItemFormat##`.

View File

@ -0,0 +1,29 @@
---
layout: page
title: Set Newscan Date Module
---
## Set Newscan Date Module
The `set_newscan_date` module allows setting newscan dates (aka pointers) for message conferences and areas as well as within the file base. Users can select specific conferences/areas or all (where applicable).
## Configuration
### Configuration Block
Available `config` block entries are as follows:
* `target`: Choose from `message` for message conferences & areas, or `file` for file base areas.
* `scanDateFormat`: Format for scan date. This format must align with the **output** of the MaskEditView (`%ME1`) MCI utilized for input. Defaults to `YYYYMMDD` (which matches mask of `####/##/##`).
### Theming
#### Message Conference & Areas
When `target` is `message`, the following `itemFormat` object is provided to MCI 2 (ie: `%SM2`):
* `conf`: An object containing:
* `confTag`: Conference tag.
* `name`: Conference name. Also available in `{text}`.
* `desc`: Conference description.
* `area`: An object containing:
* `areaTag`: Area tag.
* `name`: Area name. Also available in `{text}`.
* `desc`: Area description.
When dealing with the file base, ENiGMA½ does not currently have the ability to set newscan dates for specific areas. No `%SM2` is used in this case.
### Submit Actions
Submit action should map to `@method:scanDateSubmit` and provide `scanDate` in form data. For message conf/areas (`target` of `message`), `targetSelection` should be also be provided in form data: An index to the selected conf/area.

70
docs/modding/show-art.md Normal file
View File

@ -0,0 +1,70 @@
---
layout: page
title: User List
---
## The Show Art Module
The built in `show_art` module add some advanced ways in which you can configure your system to display art assets beyond what a standard menu entry can provide. For example, based on user selection of a file or message base area.
## Configuration
### Config Block
Available `config` block entries:
* `method`: Set the method in which to show art. See **Methods** below.
* `optional`: Is this art required or optional? If non-optional and we cannot show art based on `method`, it is an error.
* `key`: Used for some `method`s. See **Methods**
### Methods
#### Extra Args
When `method` is `extraArgs`, the module selects an *art spec* from a value found within `extraArgs` that were passed to `show_art` by `key`. Consider the following:
Given an `menu.hjson` entry:
```hjson
showWithExtraArgs: {
module: show_art
config: {
method: extraArgs
key: fooBaz
}
}
```
If the `showWithExtraArgs` menu was entered and passed `extraArgs` as the following:
```json
{
fizzBang : true,
fooBaz : "LOLART"
}
```
...then the system would use the *art spec* of `LOLART`.
#### Area & Conferences
Handy for inserting into File Base, Message Conferences, or Mesage Area selections selections. When `method` is `fileBaseArea`, `messageConf`, or `messageArea` the selected conf/area's associated *art spec* is utilized. Example:
Given a file base entry in `config.hjson`:
```hjson
areas: {
all_ur_base: {
name: All Your Base
desc: chown -r us ./base
art: ALLBASE
}
}
```
A menu entry may look like this:
```hjson
showFileBaseAreaArt: {
module: show_art
config: {
method: fileBaseArea
cls: true
pause: true
menuFlags: [ "popParent", "noHistory" ]
}
}
```
...if the user choose the "All Your Base" area, the *art spec* of `ALLBASE` would be selected and displayed.
The only difference for `messageConf` or `messageArea` methods are where the art is defined (which is always next to the conf or area declaration in `config.hjson`).
While `key` can be overridden, the system uses `areaTag` for message/file area selections, and `confTag` for conference selections by default.

View File

@ -1,17 +0,0 @@
---
layout: page
title: oputil
---
oputil is the ENiGMA½ command line utility for maintaining users, file areas and message areas, as well as
generating your initial ENiGMA½ config.
## File areas
The `oputil.js` +op utilty `fb` command has tools for managing file bases. For example, to import existing
files found within **all** storage locations tied to an area and set tags `tag1` and `tag2` to each import:
```bash
oputil.js fb scan some_area --tags tag1,tag2
```
See `oputil.js fb --help` for additional information.

View File

@ -2,14 +2,21 @@
layout: page
title: Monitoring Logs
---
ENiGMA½ does not produce much to stdout. Logs are produced by Bunyan which outputs each entry as a
JSON object.
ENiGMA½ does not produce much to stdout. Logs are produced by Bunyan which outputs each entry as a JSON object.
Start by installing bunyan and making it available on your path:
npm install bunyan -g
```bash
npm install bunyan -g
```
or with Yarn:
```bash
yarn global add bunyan
```
To tail logs in a colorized and pretty format, issue the following command:
tail -F /path/to/enigma-bbs/logs/enigma-bbs.log | bunyan
```bash
tail -F /path/to/enigma-bbs/logs/enigma-bbs.log | bunyan
```

View File

@ -3,6 +3,8 @@
const client = options.client;
const user = options.client.user;
const UserProps = require('./user_property.js');
const moment = require('moment');
function checkAccess(acsCode, value) {
@ -19,7 +21,7 @@
value = [ value ];
}
const userAccountStatus = parseInt(user.properties.account_status, 10);
const userAccountStatus = user.getPropertyAsNumber(UserProps.AccountStatus);
return value.map(n => parseInt(n, 10)).includes(userAccountStatus);
},
EC : function isEncoding() {
@ -44,15 +46,15 @@
return value.map(n => parseInt(n, 10)).includes(client.node);
},
NP : function numberOfPosts() {
const postCount = parseInt(user.properties.post_count, 10) || 0;
const postCount = user.getPropertyAsNumber(UserProps.PostCount) || 0;
return !isNaN(value) && postCount >= value;
},
NC : function numberOfCalls() {
const loginCount = parseInt(user.properties.login_count, 10);
const loginCount = user.getPropertyAsNumber(UserProps.LoginCount);
return !isNaN(value) && loginCount >= value;
},
AA : function accountAge() {
const accountCreated = moment(user.properties.account_created);
const accountCreated = moment(user.getProperty(UserProps.AccountCreated));
const now = moment();
const daysOld = accountCreated.diff(moment(), 'days');
return !isNaN(value) &&
@ -61,36 +63,36 @@
daysOld >= value;
},
BU : function bytesUploaded() {
const bytesUp = parseInt(user.properties.ul_total_bytes, 10) || 0;
const bytesUp = user.getPropertyAsNumber(UserProps.FileUlTotalBytes) || 0;
return !isNaN(value) && bytesUp >= value;
},
UP : function uploads() {
const uls = parseInt(user.properties.ul_total_count, 10) || 0;
const uls = user.getPropertyAsNumber(UserProps.FileUlTotalCount) || 0;
return !isNaN(value) && uls >= value;
},
BD : function bytesDownloaded() {
const bytesDown = parseInt(user.properties.dl_total_bytes, 10) || 0;
const bytesDown = user.getPropertyAsNumber(UserProps.FileDlTotalBytes) || 0;
return !isNaN(value) && bytesDown >= value;
},
DL : function downloads() {
const dls = parseInt(user.properties.dl_total_count, 10) || 0;
const dls = user.getPropertyAsNumber(UserProps.FileDlTotalCount) || 0;
return !isNaN(value) && dls >= value;
},
NR : function uploadDownloadRatioGreaterThan() {
const ulCount = parseInt(user.properties.ul_total_count, 10) || 0;
const dlCount = parseInt(user.properties.dl_total_count, 10) || 0;
const ulCount = user.getPropertyAsNumber(UserProps.FileUlTotalCount) || 0;
const dlCount = user.getPropertyAsNumber(UserProps.FileDlTotalCount) || 0;
const ratio = ~~((ulCount / dlCount) * 100);
return !isNaN(value) && ratio >= value;
},
KR : function uploadDownloadByteRatioGreaterThan() {
const ulBytes = parseInt(user.properties.ul_total_bytes, 10) || 0;
const dlBytes = parseInt(user.properties.dl_total_bytes, 10) || 0;
const ulBytes = user.getPropertyAsNumber(UserProps.FileUlTotalBytes) || 0;
const dlBytes = user.getPropertyAsNumber(UserProps.FileDlTotalBytes) || 0;
const ratio = ~~((ulBytes / dlBytes) * 100);
return !isNaN(value) && ratio >= value;
},
PC : function postCallRatio() {
const postCount = parseInt(user.properties.post_count, 10) || 0;
const loginCount = parseInt(user.properties.login_count, 10);
const postCount = user.getPropertyAsNumber(UserProps.PostCount) || 0;
const loginCount = user.getPropertyAsNumber(UserProps.LoginCount) || 0;
const ratio = ~~((postCount / loginCount) * 100);
return !isNaN(value) && ratio >= value;
},

View File

@ -66,7 +66,8 @@
// See https://github.com/trentm/node-bunyan#streams
//
// Remember you can pipe logs through Bunyan to pretty-print:
// tail -F ./logs/enigma-bbs.log | bunyan
// Linux : tail -F ./logs/enigma-bbs.log | bunyan
// PowerShell : Get-Content .\enigma-bbs.log -Tail 15 | bunyan.cmd
//
// (npm install -g bunyan to get the binary)
//
@ -210,6 +211,9 @@
port: XXXXX
enabled: false
// bannerFile path in misc/ by default. Full paths allowed.
bannerFile: XXXXX
//
// The Gopher Content Server can export message base
// conferences and areas via the "messageConferences" key.
@ -330,7 +334,7 @@
// ]
//
//
// Set default group(s) new users should automatically be assigned to
// Set default group(s) new users should automatically be assigned to:
// defaultGroups : [
// "lamerz"
// ]
@ -348,6 +352,23 @@
// Usernames reserved for applying to your system
newUserNames: []
// Handling of failed logins
failedLogin : {
// disconnect after N failed attempts. 0=disabled.
disconnect : XXXXX
// Lock the user out after N failed attempts. 0=disabled.
lockAccount : XXXXX
//
// If locked out, how long until the user can login again?
// Set to 0 to disable auto-unlock
//
autoUnlockMinutes : XXXXX
},
// Allow email driven password resets to unlock accounts?
unlockAtEmailPwReset : XXXXX
}
// Archive files and related
@ -378,10 +399,29 @@
//
}
//
// Use the Event Scheduler to set up arbitrary scheduled events
// using Later style syntax and/or @watch files.
// See docs/event-scheduler.md for more information.
//
eventScheduler: {
events: {
// Example:
//
// sampleEvent: {
// schedule: every 2 hours
// action: @execute:/path/to/some/script.sh
// args: [
// "--foo", "--bar"
// ]
// }
}
}
statLog: {
systemEvents: {
// Max login history event records kept. -1 = unlimited
loginHistoryMax: -1
}
}
}
}

9
misc/gopher_banner.asc Normal file
View File

@ -0,0 +1,9 @@
_____________________ _____ ____________________ __________\_ /
\__ ____/\_ ____ \ /____/ / _____ __ \ / ______/ // /___jp!
// __|___// | \// |// | \// | | \// \ /___ /_____
/____ _____| __________ ___|__| ____| \ / _____ \
---- \______\ -- |______\ ------ /______/ ---- |______\ - |______\ /__/ // ___/
/__ _\
<*> ENiGMA½ // HTTPS://GITHUB.COM/NUSKOOLER/ENIGMA-BBS <*> /__/
-------------------------------------------------------------------------------

View File

@ -145,6 +145,9 @@
next: fullLoginSequenceLoginArt
config: {
tooNodeMenu: loginAttemptTooNode
inactive: loginAttemptAccountInactive
disabled: loginAttemptAccountDisabled
locked: loginAttemptAccountLocked
}
form: {
0: {
@ -185,6 +188,34 @@
cls: true
nextTimeout: 2000
}
next: logoff
}
loginAttemptAccountLocked: {
art: ACCOUNTLOCKED
config: {
cls: true
nextTimeout: 2000
}
next: logoff
}
loginAttemptAccountDisabled: {
art: ACCOUNTDISABLED
config: {
cls: true
nextTimeout: 2000
}
next: logoff
}
loginAttemptAccountInactive: {
art: ACCOUNTINACTIVE
config: {
cls: true
nextTimeout: 2000
}
next: logoff
}
forgotPassword: {
@ -1921,7 +1952,6 @@
SM2: {
argName: targetSelection
submit: false
justify: right
}
}
submit: {

69
package-lock.json generated
View File

@ -94,11 +94,6 @@
"resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz",
"integrity": "sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg="
},
"arrify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz",
"integrity": "sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0="
},
"asn1": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.3.tgz",
@ -494,16 +489,15 @@
}
},
"del": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/del/-/del-2.2.2.tgz",
"integrity": "sha1-wSyYHQZ4RshLyvhiz/kw2Qf/0ag=",
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/del/-/del-3.0.0.tgz",
"integrity": "sha1-U+z2mf/LyzljdpGrE7rxYIGXZuU=",
"requires": {
"globby": "^5.0.0",
"globby": "^6.1.0",
"is-path-cwd": "^1.0.0",
"is-path-in-cwd": "^1.0.0",
"object-assign": "^4.0.1",
"pify": "^2.0.0",
"pinkie-promise": "^2.0.0",
"p-map": "^1.1.1",
"pify": "^3.0.0",
"rimraf": "^2.2.8"
}
},
@ -878,16 +872,22 @@
}
},
"globby": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/globby/-/globby-5.0.0.tgz",
"integrity": "sha1-69hGZ8oNuzMLmbz8aOrCvFQ3Dg0=",
"version": "6.1.0",
"resolved": "http://registry.npmjs.org/globby/-/globby-6.1.0.tgz",
"integrity": "sha1-9abXDoOV4hyFj7BInWTfAkJNUGw=",
"requires": {
"array-union": "^1.0.1",
"arrify": "^1.0.0",
"glob": "^7.0.3",
"object-assign": "^4.0.1",
"pify": "^2.0.0",
"pinkie-promise": "^2.0.0"
},
"dependencies": {
"pify": {
"version": "2.3.0",
"resolved": "http://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
"integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw="
}
}
},
"graceful-fs": {
@ -954,9 +954,9 @@
"integrity": "sha512-U/fnTE3edW0AV92ZI/BfEluMZuVcu3MDOopsN7jS+HqDYcarQo8rXQiWlsBlm0uX48/taYSdxRsfzh2HRg5Z6w=="
},
"hjson": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/hjson/-/hjson-3.1.1.tgz",
"integrity": "sha512-1oGkOq4sssz7HFZ8Is9HuTR47r8gSC46qAzQxVlAkj0lNKpS+W5Lv2eci+c5+fFqL+Idtj5EvprFreUwH29a8A=="
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/hjson/-/hjson-3.1.2.tgz",
"integrity": "sha512-2ILrho8eRl2Bniy61mDFiXRAloYqH2T6OwWkoF/8y55DPFgG2RcqQGNXIfBLp432dnAbLOpBJ4pJs63W3X27EA=="
},
"http-signature": {
"version": "1.2.0",
@ -1649,6 +1649,11 @@
"resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
"integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4="
},
"p-map": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/p-map/-/p-map-1.2.0.tgz",
"integrity": "sha512-r6zKACMNhjPJMTl8KcFH4li//gkrXWfbD6feV8l6doRHlzljFWGJ2AP6iKaCJXyZmAUMOPtvbW7EXkbWO/pLEA=="
},
"pascalcase": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz",
@ -1675,9 +1680,9 @@
"integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns="
},
"pify": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
"integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw="
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz",
"integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY="
},
"pinkie": {
"version": "2.0.4",
@ -2233,11 +2238,11 @@
}
},
"temptmp": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/temptmp/-/temptmp-1.0.0.tgz",
"integrity": "sha1-M7Djbh8nMXyKKBIO6Wufj+tw2UM=",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/temptmp/-/temptmp-1.1.0.tgz",
"integrity": "sha512-gHelQlePUzxRmodWL1uJ9LiwI+a7a3rkFGS9azTf4noPZgGOlx0dOPV9tZs5+QwGc4Nm8BfFxL9cfvV42GNxPQ==",
"requires": {
"del": "^2.2.2"
"del": "^3.0.0"
}
},
"through": {
@ -2493,9 +2498,9 @@
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
},
"ws": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-6.1.0.tgz",
"integrity": "sha512-H3dGVdGvW2H8bnYpIDc3u3LH8Wue3Qh+Zto6aXXFzvESkTVT6rAfKR6tR/+coaUvxs8yHtmNV0uioBF62ZGSTg==",
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/ws/-/ws-6.1.2.tgz",
"integrity": "sha512-rfUqzvz0WxmSXtJpPMX2EeASXabOrSMk1ruMOV3JBTBjo4ac2lDjGGsbQSyxj8Odhw5fBib8ZKEjDNvgouNKYw==",
"requires": {
"async-limiter": "~1.0.0"
}
@ -2514,9 +2519,9 @@
"integrity": "sha1-hFK0u36Dx8GI2AQcGoN8dz1ti7k="
},
"yazl": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/yazl/-/yazl-2.4.3.tgz",
"integrity": "sha1-7CblzIfVYBud+EMtvdPNLlFzoHE=",
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/yazl/-/yazl-2.5.0.tgz",
"integrity": "sha512-rgptqKwX/f1/7bIRF1FHb4HGsP5k11QyxBpDl1etUDfNpTa7CNjDOYNPFnIaEzZ9dRq0c47IEJS+sy+T39JCLw==",
"requires": {
"buffer-crc32": "~0.2.3"
}

View File

@ -31,7 +31,7 @@
"glob": "^7.1.2",
"graceful-fs": "^4.1.15",
"hashids": "^1.1.1",
"hjson": "^3.1.1",
"hjson": "^3.1.2",
"iconv-lite": "^0.4.23",
"inquirer": "^6.0.0",
"later": "1.2.0",
@ -47,12 +47,12 @@
"sqlite3": "^4.0.4",
"sqlite3-trans": "^1.2.0",
"ssh2": "^0.6.1",
"temptmp": "^1.0.0",
"temptmp": "^1.1.0",
"uuid": "^3.2.1",
"uuid-parse": "^1.0.0",
"ws": "^6.1.0",
"ws": "^6.1.2",
"xxhash": "^0.2.4",
"yazl": "^2.4.2"
"yazl": "^2.5.0"
},
"devDependencies": {},
"engines": {