Sync up with 0.0.10-alpha

This commit is contained in:
Bryan Ashby 2019-11-19 20:08:31 -07:00
commit 0bfeca090d
No known key found for this signature in database
GPG Key ID: B49EB437951D2542
97 changed files with 4568 additions and 786 deletions

View File

@ -3,7 +3,9 @@
"es6": true,
"node": true
},
"extends": "eslint:recommended",
"extends": [
"eslint:recommended"
],
"rules": {
"indent": [
"error",

10
.gitignore vendored
View File

@ -2,11 +2,13 @@
*.pem
# Various directories
config/config.hjson
logs/
config/
db/
dropfiles/
drop/
file_base/
logs/
mail/
node_modules/
docs/_site/
docs/.sass-cache/
`
.vscode/

View File

@ -17,6 +17,7 @@ ENiGMA½ is a modern BBS software with a nostalgic flair!
* Renegade style [pipe color codes](/docs/configuration/colour-codes.md).
* [SQLite](http://sqlite.org/) storage of users, message areas, etc.
* Strong [PBKDF2](https://en.wikipedia.org/wiki/PBKDF2) backed password encryption.
* Support for 2-Factor Authentication with One-Time-Passwords
* [Door support](docs/modding/door-servers.md) including common dropfile formats for legacy DOS doors. Built in [BBSLink](http://bbslink.net/), [DoorParty](http://forums.throwbackbbs.com/), [Exodus](https://oddnetwork.org/exodus/) and [CombatNet](http://combatnet.us/) support!
* [Bunyan](https://github.com/trentm/node-bunyan) logging!
* [Message networks](docs/messageareas/message-networks.md) with FidoNet Type Network (FTN) + BinkleyTerm Style Outbound (BSO) message import/export. Messages Bases can also be exposed via [Gopher](docs/servers/gopher.md), or [NNTP](docs/servers/nntp.md)!
@ -40,7 +41,7 @@ See [the issue tracker](https://github.com/NuSkooler/enigma-bbs/issues) for more
* Use [the issue tracker](https://github.com/NuSkooler/enigma-bbs/issues)
* **Discussion on a ENiGMA BBS!** (see Boards below)
* IRC: **#enigma-bbs** on **chat.freenode.net** ([webchat](https://webchat.freenode.net/?channels=enigma-bbs))
* Discussion on [fsxNet](http://bbs.geek.nz/#fsxNet) available on many boards
* FSX_ENG on [fsxNet](http://bbs.geek.nz/#fsxNet) available on many boards
* Email: bryan -at- l33t.codes
* [Facebook ENiGMA½ group](https://www.facebook.com/groups/enigmabbs/)
@ -52,17 +53,17 @@ ENiGMA has been tested with many terminals. However, the following are suggested
* [NetRunner](http://mysticbbs.com/downloads.html)
* [MagiTerm](https://magickabbs.com/index.php/magiterm/)
## Boards
* WQH: :skull: [Xibalba](https://l33t.codes/xibalba-bbs) :skull: (**ssh://xibalba.l33t.codes:44511** or **telnet://xibalba.l33t.codes:44510**)
## Some Boards
* :skull: [Xibalba - ENiGMA WHQ](https://l33t.codes/xibalba-bbs) :skull: (**ssh://xibalba.l33t.codes:44511** or **telnet://xibalba.l33t.codes:44510**)
* [fORCE9](http://bbs.force9.org/): (**telnet://bbs.force9.org**)
* [Undercurrents](https://undercurrents.io): (**ssh://undercurrents.io**)
* [PlaneT Afr0](https://planetafr0.org/): (**ssh://planetafr0.org:8889**)
* [Goblin Studio](https://goblin.strangled.net): (**ssh://goblin.strangled.net:8889**)
## Installation
On *nix type systems:
```
curl -o- https://raw.githubusercontent.com/NuSkooler/enigma-bbs/master/misc/install.sh | bash
curl -o- https://raw.githubusercontent.com/NuSkooler/enigma-bbs/0.0.10-alpha/misc/install.sh | bash
```
Please see [Installation Methods](https://nuskooler.github.io/enigma-bbs/installation/installation-methods.html) for Windows, Docker, and so on...
@ -76,11 +77,12 @@ Please see [Installation Methods](https://nuskooler.github.io/enigma-bbs/install
* [Luciano Ayres](http://www.lucianoayres.com.br/) of [Blocktronics](http://blocktronics.org/), creator of the "Mystery Skulls" default ENiGMA½ theme!
* Sudndeath for Xibalba ANSI work!
* Jack Phlash for kick ass ENiGMA½ and Xibalba ASCII (Check out [IMPURE60](http://pc.textmod.es/pack/impure60/)!!)
* Avon of [Agency BBS](http://bbs.geek.nz/) and [fsxNet](http://bbs.geek.nz/#fsxNet) for putting up with my experiments to his system
* Avon of [Agency BBS](http://bbs.geek.nz/) and [fsxNet](http://bbs.geek.nz/#fsxNet) for putting up with my experiments to his system and for FSX_ENG!
* Maskreet of [Throwback BBS](http://www.throwbackbbs.com/) hosting [DoorParty](http://forums.throwbackbbs.com/)!
* [Apam](https://github.com/apamment) of [Magicka](https://magickabbs.com/)
* [nail/blocktronics](http://blocktronics.org/tag/nail/) for the [sickmade Xibalba logo](http://pc.textmod.es/pack/blocktronics-420/n-xbalba.ans)!
* [Whazzit/blocktronics](http://blocktronics.org/tag/whazzit/) for the amazing Mayan ANSI pieces scattered about Xibalba BBS!
* [Smooth](https://16colo.rs/tags/artist/smooth)/[fUEL](https://fuel.wtf/) for lots of dope art. Why not [snag a T-Shirt](https://www.redbubble.com/people/araknet/works/39126831-enigma-1-2-software-logo-design-by-smooth-of-fuel?p=t-shirt)?
## License
Released under the [BSD 2-clause](https://opensource.org/licenses/BSD-2-Clause) license:

View File

@ -8,7 +8,7 @@ This document covers basic upgrade notes for major ENiGMA½ version updates.
# General Notes
## Configuration File Updates
In general, look at the `menu_template.in.hjson`, and `config_template.in.hjson` as well as the defualt `luciano_blocktronics/theme.hjson` files when you update. These files may come with new sections you wish to merge into your system!
In general, look at the `menu_template.in.hjson`, and `config_template.in.hjson` as well as the default `luciano_blocktronics/theme.hjson` files when you update. These files may come with new sections you wish to merge into your system!
### menu.hjson
Upgrades often come with changes to the default `menu_template.in.hjson`. It is wise to use a *different* file name for your BBS's version of this file and point to it via `config.hjson`. For example:
@ -37,9 +37,13 @@ npm install
```
# Problems
Report your issue on Xibalba BBS, hop in #enigma-bbs on Freenet and chat, or
Report your issue on Xibalba BBS, hop in #enigma-bbs on FreeNode and chat, or
[file a issue on GitHub](https://github.com/NuSkooler/enigma-bbs/issues).
# 0.0.9-alpha to 0.0.10-alpha
* Security related files such as private keys and certs are now looked for in `config/security` by default.
* Default archive handler for zip files has switched to InfoZip due to a bug in the latest p7Zip packages causing "volume not found" errors. Ensure you have the InfoZip `zip` and `unzip` commands in ENiGMA's path. You can switch back to 7Zip by overriding `archiveHandler` for `application/zip` in your `config.hjson` under `fileTypes` to `7Zip`.
# 0.0.8-alpha to 0.0.9-alpha
* Development is now against Node.js 10.x LTS. Follow your standard upgrade path to update to Node 10.x before using 0.0.9-alpha!
* The property `justify` found on various views previously had `left` and `right` values swapped (oops!); you will need to adjust any custom `theme.hjson` that use one or the other and swap them as well.

View File

@ -1,6 +1,53 @@
# Whats New
This document attempts to track **major** changes and additions in ENiGMA½. For details, see GitHub.
## 0.0.10-alpha
+ `oputil.js user rename USERNAME NEWNAME`
+ `my_messages.js` module (defaulted to "m" at the message menu) to list public messages addressed to the currently logged in user. Takes into account their username and `real_name` property.
+ SSH Public Key Authentication has been added. The system uses a OpenSSH style public key set on the `ssh_public_key` user property.
+ 2-Factor (2FA) authentication is now available using [RFC-4266 - HOTP: HMAC-Based One-Time Password Algorithm)](https://tools.ietf.org/html/rfc4226), [RFC-6238 - TOTP: Time-Based One-Time Password Algorithm](https://tools.ietf.org/html/rfc6238), or [Google Authenticator](http://google-authenticator.com/). QR codes for activation are available as well. One-time backup aka recovery codes can also be used. See [Security](/docs/configuration/security.md) for more info!
* New ACS codes for new 2FA/OTP: `AR` and `AF`. See [ACS](/docs/configuration/acs.md) for details.
+ `oputil.js user 2fa USERNAME TYPE` enables 2-factor authentication for a user.
* `oputil.js user info USERNAME --security` can now display additional security information such as 2FA/OTP.
* `oputil.js fb scan --quick` is now the default. Override with `--full`.
* ACS checks can now be applied to form actions. For example:
```hjson
{
value: { command: "SEC" }
action: [
{
// secure connections can go here
acs: SC
action: @menu:securityMenu
}
{
// non-secure connections
action: @menu:secureConnectionRequired
}
]
}
```
* `idleLogoutSeconds` and `preAuthIdleLogoutSeconds` can now be set to `0` to fully disable the idle monitor.
* Switched default archive handler for zip files from 7zip to InfoZip (`zip` and `unzip`) commands. See [UPGRADE](UPGRADE.md).
* Menu submit `action`'s can now in addition to being a simple string such as `@menu:someMenu`, or an array of objects with ACS checks, be a simple array of strings. In this case, a random match will be made. For example:
```hjson
submit: [
{
value: { command: "FOO" }
action: [
// one of the following actions will be matched:
"@menu:menuStyle1"
"@menu:menuStyle2"
]
}
]
```
* Added `read` (list/view) and `write` (post) ACS support to message conferences and areas.
* Many new built in modules adding support for things like auto signatures, listing "my" messages, top stats, etc. Take a look in the docs for setting them up!
* Built in MRC support!
* Added an customizable achievement system!
## 0.0.9-alpha
* Development is now against Node.js 10.x LTS. While other Node.js series may continue to work, you're own your own and YMMV!
* Fixed `justify` properties: `left` and `right` values were formerly swapped (oops!)
@ -17,7 +64,7 @@ This document attempts to track **major** changes and additions in ENiGMA½. For
* Add ability to skip file and/or message areas during newscan. Set config.omitFileAreaTags and config.omitMessageAreaTags in new_scan configuration of your menu.hjson
* `{userName}` (sanitized) and `{userNameRaw}` as well as `{cwd}` have been added to param options when launching a door.
* Any module may now register for a system startup initialization via the `initializeModules(initInfo, cb)` export.
* User event log is now functional. Various events a user performs will be persisted to the `system.db` `user_event_log` table for up to 90 days. An example usage can be found in the updated `last_callers` module where events are turned into Ami/X style actions. Please see `UPGRADE.md`!
* User event log is now functional. Various events a user performs will be persisted to the `system.sqlite3` `user_event_log` table for up to 90 days. An example usage can be found in the updated `last_callers` module where events are turned into Ami/X style actions. Please see `UPGRADE.md`!
* 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.
@ -27,7 +74,7 @@ This document attempts to track **major** changes and additions in ENiGMA½. For
* Performing a file scan/import using `oputil.js fb scan` now recognizes various `FILES.BBS` formats.
* Usernames found in the `config.users.badUserNames` are now not only disallowed from applying, but disconnected at any login attempt.
* Total minutes online is now tracked for users. Of course, it only starts after you get the update :)
* Form entries in `menu.hjson` can now be omitted from submission handlers using `omit: true`
## 0.0.8-alpha
* [Mystic BBS style](http://wiki.mysticbbs.com/doku.php?id=displaycodes) extended pipe color codes. These allow for example, to set "iCE" background colors.
@ -40,7 +87,7 @@ This document attempts to track **major** changes and additions in ENiGMA½. For
* Added web (http://, https://) based download manager including batch downloads. Clickable links if using [VTXClient](https://github.com/codewar65/VTX_ClientServer)!
* General VTX hyperlink support for web links
* DEL vs Backspace key differences in FSE
* Correly parse oddball `INTL`, `TOPT`, `FMPT`, `Via`, etc. FTN kludge lines
* Correctly parse oddball `INTL`, `TOPT`, `FMPT`, `Via`, etc. FTN kludge lines
* NetMail support! You can now send and receive NetMail. To send a NetMail address a external user using `Name <address>` format from your personal email menu. For example, `Foo Bar <123:123/123>`. The system also detects other formats such asa `Name @ address` (`Foo Bar@123:123/123`)
* `oputil.js`: Added `mb areafix` command to quickly send AreaFix messages from the command line. You can manually send them from personal mail as well.
* `oputil.js fb rm|remove|del|delete` functionality to remove file base entries.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -385,6 +385,47 @@
}
}
userTwoFactorAuthOTPConfig: {
config: {
menuInfoFormat10: "{infoText}"
infoText: {
disabled: Enabling 2-factor authentication can greatly increase account security.
}
}
mci: {
TM1: {
width: 20
items: [
"disabled"
"enabled"
]
focusTextStyle: upper
styleSGR1: |08
}
SM2: {
width: 20
focusTextStyle: upper
styleSGR1: |08
items: [
// order is important:
"Time-Based - TOTP"
"HMAC-Based - HOTP"
"Google Auth"
]
}
TM3: {
focusTextStyle: upper
styleSGR1: |00|08
}
MT10: {
width: 31
height: 3
mode: preview
acceptsFocus: false
}
}
}
nodeMessage: {
config: {
messageFormat: "|00|08 :: |03message from |11{fromUserName} |08/ |03node |11{fromNodeId}|08 @ |11{timestamp} |08::\r\n|07 {message}"
@ -406,6 +447,20 @@
}
}
editAutoSignature: {
0: {
mci: {
MT1: {
height: 8
width: 73
}
BT2: {
focusTextStyle: upper
}
}
}
}
messageSearch: {
0: {
mci: {
@ -450,6 +505,21 @@
}
}
messageAreaMyMessagesList: {
config: {
// Fri Sep 25th
dateTimeFormat: ddd MMM Do
}
mci: {
VM1: {
height: 16
width: 71
itemFormat: "|00|15 {msgNum:<4.4} |03{subject:<34.33} {fromUserName:<19.18} |03{ts:<12.12}"
focusItemFormat: "|00|19> |15{msgNum:<4.4} {subject:<34.33} {fromUserName:<19.18} {ts:<12.12}"
}
}
}
messageAreaViewPost: {
0: {
mci: {
@ -1063,12 +1133,65 @@
}
}
//////////////////////////////// ERC ///////////////////////////////
ercClient: {
mrc: {
config: {
//chatEntryFormat: "|00|08[|03{bbsTag}|08] |10{userName}|08: |02{message}"
messageFormat: "|00|10<|02{fromUserName}|10>|00 |03{message}|00"
privateMessageFormat: "|00|10<|02{fromUserName}|15->{toUserName}|10>|00 |03{message}|00"
}
0: {
mci: {
MT1: {
width: 72
height: 18
}
ET2: {
width: 69 // fnarr!
maxLength: 140
}
TL3: {
width: 20
}
TL4: {
width: 20
}
TL5: {
width: 2
}
TL6: {
width: 2
}
}
}
}
irc: {
config: {
messageFormat: "|00|10<|02{fromUserName}|10>|00 |03{message}|00"
privateMessageFormat: "|00|10<|02{fromUserName}|15->{toUserName}|10>|00 |03{message}|00"
}
0: {
mci: {
MT1: {
width: 72
height: 17
}
ET2: {
width: 69 // fnarr!
maxLength: 140
}
TL3: {
width: 20
}
TL4: {
width: 20
}
TL5: {
width: 2
}
TL6: {
width: 2
}
}
}
}
}

View File

@ -79,11 +79,27 @@ exports.getModule = class AbracadabraModule extends MenuModule {
/*
:TODO:
* disconnecting wile door is open leaves dosemu
* disconnecting while door is open leaves dosemu
* http://bbslink.net/sysop.php support
* Font support ala all other menus... or does this just work?
*/
incrementActiveDoorNodeInstances() {
if(activeDoorNodeInstances[this.config.name]) {
activeDoorNodeInstances[this.config.name] += 1;
} else {
activeDoorNodeInstances[this.config.name] = 1;
}
this.activeDoorInstancesIncremented = true;
}
decrementActiveDoorNodeInstances() {
if(true === this.activeDoorInstancesIncremented) {
activeDoorNodeInstances[this.config.name] -= 1;
this.activeDoorInstancesIncremented = false;
}
}
initSequence() {
const self = this;
@ -116,14 +132,8 @@ exports.getModule = class AbracadabraModule extends MenuModule {
});
}
} else {
// :TODO: JS elegant way to do this?
if(activeDoorNodeInstances[self.config.name]) {
activeDoorNodeInstances[self.config.name] += 1;
} else {
activeDoorNodeInstances[self.config.name] = 1;
}
callback(null);
self.incrementActiveDoorNodeInstances();
return callback(null);
}
},
function prepareDoor(callback) {
@ -169,6 +179,13 @@ exports.getModule = class AbracadabraModule extends MenuModule {
this.doorInstance.run(exeInfo, () => {
trackDoorRunEnd(doorTracking);
this.decrementActiveDoorNodeInstances();
// client may have disconnected while process was active -
// we're done here if so.
if(!this.client.term.output) {
return;
}
//
// Try to clean up various settings such as scroll regions that may
@ -188,9 +205,7 @@ exports.getModule = class AbracadabraModule extends MenuModule {
leave() {
super.leave();
if(!this.lastError) {
activeDoorNodeInstances[this.config.name] -= 1;
}
this.decrementActiveDoorNodeInstances();
}
finishedLoading() {

View File

@ -14,6 +14,20 @@ class ACS {
this.subject = subject;
}
static get Defaults() {
return {
MessageConfRead : 'GM[users]', // list/read
MessageConfWrite : 'GM[users]', // post/write
MessageAreaRead : 'GM[users]', // list/read; requires parent conf read
MessageAreaWrite : 'GM[users]', // post/write; requires parent conf write
FileAreaRead : 'GM[users]', // list
FileAreaWrite : 'GM[sysops]', // upload
FileAreaDownload : 'GM[users]', // download
};
}
check(acs, scope, defaultAcs) {
acs = acs ? acs[scope] : defaultAcs;
acs = acs || defaultAcs;
@ -32,10 +46,18 @@ class ACS {
return this.check(conf.acs, 'read', ACS.Defaults.MessageConfRead);
}
hasMessageConfWrite(conf) {
return this.check(conf.acs, 'write', ACS.Defaults.MessageConfWrite);
}
hasMessageAreaRead(area) {
return this.check(area.acs, 'read', ACS.Defaults.MessageAreaRead);
}
hasMessageAreaWrite(area) {
return this.check(area.acs, 'write', ACS.Defaults.MessageAreaWrite);
}
//
// File Base / Areas
//
@ -44,6 +66,7 @@ class ACS {
}
hasFileAreaWrite(area) {
// :TODO: create 'upload' alias?
return this.check(area.acs, 'write', ACS.Defaults.FileAreaWrite);
}
@ -91,13 +114,4 @@ class ACS {
}
}
ACS.Defaults = {
MessageAreaRead : 'GM[users]',
MessageConfRead : 'GM[users]',
FileAreaRead : 'GM[users]',
FileAreaWrite : 'GM[sysops]',
FileAreaDownload : 'GM[users]',
};
module.exports = ACS;

View File

@ -846,6 +846,7 @@ function peg$parse(input, options) {
const UserProps = require('./user_property.js');
const Log = require('./logger.js').log;
const User = require('./user.js');
const _ = require('lodash');
const moment = require('moment');
@ -982,6 +983,22 @@ function peg$parse(input, options) {
SC : function isSecureConnection() {
return _.get(client, 'session.isSecure', false);
},
AF : function currentAuthFactor() {
if(!user) {
return false;
}
return !isNaN(value) && user.authFactor >= value;
},
AR : function authFactorRequired() {
if(!user) {
return false;
}
switch(value) {
case 1 : return true;
case 2 : return user.getProperty(UserProps.AuthFactor2OTP) ? true : false;
default : return false;
}
},
ML : function minutesLeft() {
// :TODO: implement me!
return false;

View File

@ -57,6 +57,9 @@ function sliceAtEOF(data, eofMarker) {
break;
}
}
if(eof === data.length || eof < 128) {
return data;
}
return data.slice(0, eof);
}
@ -144,7 +147,7 @@ function getArt(name, options, cb) {
// If an extension is provided, just read the file now
if('' !== ext) {
const directPath = paths.join(options.basePath, name);
const directPath = paths.isAbsolute(name) ? name : paths.join(options.basePath, name);
return getArtFromPath(directPath, options, cb);
}

76
core/autosig_edit.js Normal file
View File

@ -0,0 +1,76 @@
/* jslint node: true */
'use strict';
// ENiGMA½
const { MenuModule } = require('./menu_module.js');
const UserProps = require('./user_property.js');
// deps
const async = require('async');
const _ = require('lodash');
exports.moduleInfo = {
name : 'User Auto-Sig Editor',
desc : 'Module for editing auto-sigs',
author : 'NuSkooler',
};
const FormIds = {
edit : 0,
};
const MciViewIds = {
editor : 1,
save : 2,
};
exports.getModule = class UserAutoSigEditorModule extends MenuModule {
constructor(options) {
super(options);
this.config = Object.assign({}, _.get(options, 'menuConfig.config'), { extraArgs : options.extraArgs });
this.menuMethods = {
saveChanges : (formData, extraArgs, cb) => {
return this.saveChanges(cb);
}
};
}
mciReady(mciData, cb) {
super.mciReady(mciData, err => {
if(err) {
return cb(err);
}
async.series(
[
(callback) => {
return this.prepViewController('edit', FormIds.edit, mciData.menu, callback);
},
(callback) => {
const requiredCodes = [ MciViewIds.editor, MciViewIds.save ];
return this.validateMCIByViewIds('edit', requiredCodes, callback);
},
(callback) => {
const sig = this.client.user.getProperty(UserProps.AutoSignature) || '';
this.setViewText('edit', MciViewIds.editor, sig);
return callback(null);
}
],
err => {
return cb(err);
}
);
});
}
saveChanges(cb) {
const sig = this.getView('edit', MciViewIds.editor).getData().trim();
this.client.user.persistProperty(UserProps.AutoSignature, sig, err => {
if(err) {
this.client.log.error( { error : err.message }, 'Could not save auto-sig');
}
return this.prevMenu(cb);
});
}
};

View File

@ -306,6 +306,10 @@ function initialize(cb) {
const WebPasswordReset = require('./web_password_reset.js').WebPasswordReset;
return WebPasswordReset.startup(callback);
},
function ready2FA_OTPRegister(callback) {
const User2FA_OTPWebRegister = require('./user_2fa_otp_web_register.js');
return User2FA_OTPWebRegister.startup(callback);
},
function readyEventScheduler(callback) {
const EventSchedulerModule = require('./event_scheduler.js').EventSchedulerModule;
EventSchedulerModule.loadAndStart( (err, modInst) => {

View File

@ -135,16 +135,21 @@ exports.getModule = class BBSLinkModule extends MenuModule {
};
let clientTerminated;
let dataOut;
self.client.term.write(resetScreen());
self.client.term.write(' Connecting to BBSLink.net, please wait...\n');
self.client.term.write(` Connecting to ${self.config.host}, please wait...\n`);
const doorTracking = trackDoorRunBegin(self.client, `bbslink_${self.config.door}`);
const bridgeConnection = net.createConnection(connectOpts, function connected() {
self.client.log.info(connectOpts, 'BBSLink bridge connection established');
self.client.term.output.pipe(bridgeConnection);
dataOut = (data) => {
return bridgeConnection.write(data);
};
self.client.term.output.on('data', dataOut);
self.client.once('end', function clientEnd() {
self.client.log.info('Connection ended. Terminating BBSLink connection');
@ -153,9 +158,11 @@ exports.getModule = class BBSLinkModule extends MenuModule {
});
});
const restorePipe = function() {
self.client.term.output.unpipe(bridgeConnection);
self.client.term.output.resume();
const restore = () => {
if(dataOut && self.client.term.output) {
self.client.term.output.removeListener('data', dataOut);
dataOut = null;
}
trackDoorRunEnd(doorTracking);
};
@ -167,14 +174,14 @@ exports.getModule = class BBSLinkModule extends MenuModule {
});
bridgeConnection.on('end', function connectionEnd() {
restorePipe();
restore();
return callback(clientTerminated ? Errors.General('Client connection terminated') : null);
});
bridgeConnection.on('error', function error(err) {
self.client.log.info('BBSLink bridge connection error: ' + err.message);
restorePipe();
callback(err);
restore();
return callback(err);
});
}
],

View File

@ -136,14 +136,8 @@ function Client(/*input, output*/) {
//
this.getTermClient = function(deviceAttr) {
let termClient = {
//
// See http://www.fbl.cz/arctel/download/techman.pdf
//
// Known clients:
// * Irssi ConnectBot (Android)
//
'63;1;2' : 'arctel',
'50;86;84;88' : 'vtx',
'63;1;2' : 'arctel', // http://www.fbl.cz/arctel/download/techman.pdf - Irssi ConnectBot (Android)
'50;86;84;88' : 'vtx', // https://github.com/codewar65/VTX_ClientServer/blob/master/vtx.txt
}[deviceAttr];
if(!termClient) {
@ -439,6 +433,11 @@ Client.prototype.setTermType = function(termType) {
};
Client.prototype.startIdleMonitor = function() {
// clear existing, if any
if(this.idleCheck) {
this.stopIdleMonitor();
}
this.lastKeyPressMs = Date.now();
//
@ -474,14 +473,28 @@ Client.prototype.startIdleMonitor = function() {
idleLogoutSeconds = Config().users.preAuthIdleLogoutSeconds;
}
if(nowMs - this.lastKeyPressMs >= (idleLogoutSeconds * 1000)) {
// use override value if set
idleLogoutSeconds = this.idleLogoutSecondsOverride || idleLogoutSeconds;
if(idleLogoutSeconds > 0 && (nowMs - this.lastKeyPressMs >= (idleLogoutSeconds * 1000))) {
this.emit('idle timeout');
}
}, 1000 * 60);
};
Client.prototype.stopIdleMonitor = function() {
clearInterval(this.idleCheck);
if(this.idleCheck) {
clearInterval(this.idleCheck);
delete this.idleCheck;
}
};
Client.prototype.overrideIdleLogoutSeconds = function(seconds) {
this.idleLogoutSecondsOverride = seconds;
};
Client.prototype.restoreIdleLogoutSeconds = function() {
delete this.idleLogoutSecondsOverride;
};
Client.prototype.end = function () {

View File

@ -9,7 +9,7 @@ const UserProps = require('./user_property.js');
// deps
const _ = require('lodash');
const moment = require('moment');
const hashids = require('hashids');
const hashids = require('hashids/cjs');
exports.getActiveConnections = getActiveConnections;
exports.getActiveConnectionList = getActiveConnectionList;

View File

@ -169,6 +169,11 @@ function getDefaultConfig() {
return {
general : {
boardName : 'Another Fine ENiGMA½ BBS',
prettyBoardName : '|08A|07nother |07F|08ine |07E|08NiGMA|07½ B|08BS',
telnetHostname : '',
sshHostname : '',
website : 'https://enigma-bbs.github.io',
description : 'An ENiGMA½ BBS',
// :TODO: closedSystem prob belongs under users{}?
closedSystem : false, // is the system closed to new users?
@ -212,7 +217,8 @@ function getDefaultConfig() {
badUserNames : [
'sysop', 'admin', 'administrator', 'root', 'all',
'areamgr', 'filemgr', 'filefix', 'areafix', 'allfix'
'areamgr', 'filemgr', 'filefix', 'areafix', 'allfix',
'server', 'client', 'notme'
],
preAuthIdleLogoutSeconds : 60 * 3, // 3m
@ -224,6 +230,16 @@ function getDefaultConfig() {
autoUnlockMinutes : 60 * 6, // 0=disabled; Auto unlock after N minutes.
},
unlockAtEmailPwReset : true, // if true, password reset via email will unlock locked accounts
twoFactorAuth : {
method : 'googleAuth',
otp : {
registerEmailText : paths.join(__dirname, '../misc/otp_register_email.template.txt'),
registerEmailHtml : paths.join(__dirname, '../misc/otp_register_email.template.html'),
registerPageTemplate : paths.join(__dirname, '../www/otp_register.template.html'),
}
}
},
theme : {
@ -250,16 +266,18 @@ function getDefaultConfig() {
paths : {
config : paths.join(__dirname, './../config/'),
security : paths.join(__dirname, './../config/security'), // certs, keys, etc.
mods : paths.join(__dirname, './../mods/'),
loginServers : paths.join(__dirname, './servers/login/'),
contentServers : paths.join(__dirname, './servers/content/'),
chatServers : paths.join(__dirname, './servers/chat/'),
scannerTossers : paths.join(__dirname, './scanner_tossers/'),
mailers : paths.join(__dirname, './mailers/') ,
art : paths.join(__dirname, './../art/general/'),
themes : paths.join(__dirname, './../art/themes/'),
logs : paths.join(__dirname, './../logs/'), // :TODO: set up based on system, e.g. /var/logs/enigmabbs or such
logs : paths.join(__dirname, './../logs/'),
db : paths.join(__dirname, './../db/'),
modsDb : paths.join(__dirname, './../db/mods/'),
dropFiles : paths.join(__dirname, './../drop/'), // + "/node<x>/
@ -284,10 +302,10 @@ function getDefaultConfig() {
//
// > openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \
// -pkeyopt rsa_keygen_pubexp:65537 | openssl rsa \
// -out ./config/ssh_private_key.pem -aes128
// -out ./config/security/ssh_private_key.pem -aes128
//
// (The above is a more modern equivelant of the following):
// > openssl genrsa -aes128 -out ./config/ssh_private_key.pem 2048
// (The above is a more modern equivalent of the following):
// > openssl genrsa -aes128 -out ./config/security/ssh_private_key.pem 2048
//
// 2 - Set 'privateKeyPass' to the password you used in step #1
//
@ -297,7 +315,7 @@ function getDefaultConfig() {
// - https://blog.sleeplessbeastie.eu/2017/12/28/how-to-generate-private-key/
// - https://gist.github.com/briansmith/2ee42439923d8e65a266994d0f70180b
//
privateKeyPem : paths.join(__dirname, './../config/ssh_private_key.pem'),
privateKeyPem : paths.join(__dirname, './../config/security/ssh_private_key.pem'),
firstMenu : 'sshConnected',
firstMenuNewUser : 'sshConnectedNewUser',
@ -447,6 +465,16 @@ function getDefaultConfig() {
}
},
chatServers : {
mrc: {
enabled : false,
serverHostname : 'mrc.bottomlessabyss.net',
serverPort : 5000,
retryDelay : 10000,
multiplexerPort : 5000,
}
},
infoExtractUtils : {
Exiftool2Desc : {
cmd : `${__dirname}/../util/exiftool2desc.js`, // ensure chmod +x
@ -549,7 +577,7 @@ function getDefaultConfig() {
desc : 'ZIP Archive',
sig : '504b0304',
offset : 0,
archiveHandler : '7Zip',
archiveHandler : 'InfoZip',
},
/*
'application/x-cbr' : {
@ -623,7 +651,7 @@ function getDefaultConfig() {
archives : {
archivers : {
'7Zip' : {
'7Zip' : { // p7zip package
compress : {
cmd : '7za',
args : [ 'a', '-tzip', '{archivePath}', '{fileList}' ],
@ -643,6 +671,27 @@ function getDefaultConfig() {
},
},
InfoZip: {
compress : {
cmd : 'zip',
args : [ '{archivePath}', '{fileList}' ],
},
decompress : {
cmd : 'unzip',
args : [ '{archivePath}', '-d', '{extractPath}' ],
},
list : {
cmd : 'unzip',
args : [ '-l', '{archivePath}' ],
// Annoyingly, dates can be in YYYY-MM-DD or MM-DD-YYYY format
entryMatch : '^\\s*([0-9]+)\\s+[0-9]{2,4}-[0-9]{2}-[0-9]{2,4}\\s+[0-9]{2}:[0-9]{2}\\s+([^\\r\\n]+)$',
},
extract : {
cmd : 'unzip',
args : [ '{archivePath}', '{fileList}', '-d', '{extractPath}' ],
}
},
Lha : {
//
// 'lha' command can be obtained from:
@ -998,6 +1047,15 @@ function getDefaultConfig() {
args : [ '24 hours' ] // items older than this will be removed
},
twoFactorRegisterTokenMaintenance : {
schedule : 'every 24 hours',
action : '@method:core/user_temp_token.js:temporaryTokenMaintenanceTask',
args : [
'auth_factor2_otp_register',
'24 hours', // expire time
]
},
//
// Enable the following entry in your config.hjson to periodically create/update
// DESCRIPT.ION files for your file base

View File

@ -112,7 +112,6 @@ function ansiAttemptDetectUTF8(client, cb) {
}
}
return cb(null);
}
};

View File

@ -203,6 +203,22 @@ const DB_INIT_TABLE = {
);`
);
//
// Table for temporary tokens, generally used for e.g. 'outside'
// access such as email links.
// Examples: PW reset, enabling of 2FA/OTP, etc.
//
dbs.user.run(
`CREATE TABLE IF NOT EXISTS user_temporary_token (
user_id INTEGER NOT NULL,
token VARCHAR NOT NULL,
token_type VARCHAR NOT NULL,
timestamp DATETIME NOT NULL,
UNIQUE(user_id, token_type),
FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE
);`
);
return cb(null);
},

View File

@ -70,14 +70,13 @@ module.exports = class Door {
const args = exeInfo.args.map( arg => stringFormat(arg, formatObj) );
this.client.log.debug(
this.client.log.info(
{ cmd : exeInfo.cmd, args, io : this.io },
'Executing door'
'Executing external door process'
);
let door;
try {
door = pty.spawn(exeInfo.cmd, args, {
this.doorPty = pty.spawn(exeInfo.cmd, args, {
cols : this.client.term.termWidth,
rows : this.client.term.termHeight,
cwd : cwd,
@ -88,15 +87,19 @@ module.exports = class Door {
return cb(e);
}
this.client.log.debug(
{ processId : this.doorPty.pid }, 'External door process spawned'
);
if('stdio' === this.io) {
this.client.log.debug('Using stdio for door I/O');
this.client.term.output.pipe(door);
this.client.term.output.pipe(this.doorPty);
door.on('data', this.doorDataHandler.bind(this));
this.doorPty.on('data', this.doorDataHandler.bind(this));
door.once('close', () => {
return this.restoreIo(door);
this.doorPty.once('close', () => {
return this.restoreIo(this.doorPty);
});
} else if('socket' === this.io) {
this.client.log.debug(
@ -105,7 +108,7 @@ module.exports = class Door {
);
}
door.once('exit', exitCode => {
this.doorPty.once('exit', exitCode => {
this.client.log.info( { exitCode : exitCode }, 'Door exited');
if(this.sockServer) {
@ -114,10 +117,11 @@ module.exports = class Door {
// we may not get a close
if('stdio' === this.io) {
this.restoreIo(door);
this.restoreIo(this.doorPty);
}
door.removeAllListeners();
this.doorPty.removeAllListeners();
delete this.doorPty;
return cb(null);
});
@ -128,9 +132,15 @@ module.exports = class Door {
}
restoreIo(piped) {
if(!this.restored && this.client.term.output) {
this.client.term.output.unpipe(piped);
this.client.term.output.resume();
if(!this.restored) {
if(this.doorPty) {
this.doorPty.kill();
}
if(this.client.term.output) {
this.client.term.output.unpipe(piped);
this.client.term.output.resume();
}
this.restored = true;
}
}

View File

@ -15,7 +15,7 @@ exports.sendMail = sendMail;
function sendMail(message, cb) {
const config = Config();
if(!_.has(config, 'email.transport')) {
return cb(Errors.MissingConfig('Email "email::transport" configuration missing'));
return cb(Errors.MissingConfig('Email "email.transport" configuration missing'));
}
message.from = message.from || config.email.defaultFrom;

View File

@ -51,4 +51,5 @@ exports.ErrorReasons = {
Inactive : 'INACTIVE',
Locked : 'LOCKED',
NotAllowed : 'NOTALLOWED',
Invalid2FA : 'INVALID2FA',
};

View File

@ -19,7 +19,7 @@ const UserProps = require('./user_property.js');
const SysProps = require('./system_menu_method.js');
// deps
const hashids = require('hashids');
const hashids = require('hashids/cjs');
const moment = require('moment');
const paths = require('path');
const async = require('async');

View File

@ -401,7 +401,7 @@ function extractAndProcessDescFiles(fileEntry, filePath, archiveEntries, cb) {
//
// :TODO: This isn't really always the case - how to handle this? We could do a quick detection...
const decodedData = iconv.decode(data, 'cp437');
fileEntry[descType] = sliceAtSauceMarker(decodedData, 0x1a);
fileEntry[descType] = sliceAtSauceMarker(decodedData);
fileEntry[`${descType}Src`] = 'descFile';
return next(null);
});
@ -575,7 +575,7 @@ function populateFileEntryInfoFromFile(fileEntry, filePath, cb) {
`${_.upperFirst(descType)} description command failed`
);
} else {
stdout = (stdout || '').trim();
stdout = stdout.trim();
if(stdout.length > 0) {
const key = 'short' === descType ? 'desc' : 'descLong';
if('desc' === key) {

View File

@ -205,7 +205,7 @@ exports.getModule = class TransferFileModule extends MenuModule {
let tryDstPath;
async.until(
() => movedOk, // until moved OK
(callback) => callback(null, movedOk), // until moved OK
(cb) => {
if(0 === renameIndex) {
// try originally supplied path first

View File

@ -38,7 +38,7 @@ function moveOrCopyFileWithCollisionHandling(src, dst, operation, cb) {
}
async.until(
() => opOk, // until moved OK
(callback) => callback(null, opOk), // until moved OK
(cb) => {
if(0 === renameIndex) {
// try originally supplied path first

View File

@ -307,7 +307,7 @@ exports.FullScreenEditorModule = exports.getModule = class FullScreenEditorModul
fromUserId : _.get(this.message, 'meta.System.local_from_user_id', localUserIdNotAvail),
toUserId : _.get(this.message, 'meta.System.local_to_user_id', localUserIdNotAvail),
fromRemoteUser : _.get(this.message, 'meta.System.remote_from_user', remoteUserNotAvail),
toRemoteUser : _.get(this.messgae, 'meta.System.remote_to_user', remoteUserNotAvail),
toRemoteUser : _.get(this.message, 'meta.System.remote_to_user', remoteUserNotAvail),
subject : this.message.subject,
modTimestamp : this.message.modTimestamp.format(modTimestampFormat),
msgNum : this.messageIndex + 1,
@ -325,14 +325,21 @@ exports.FullScreenEditorModule = exports.getModule = class FullScreenEditorModul
buildMessage(cb) {
const headerValues = this.viewControllers.header.getFormData().value;
const area = getMessageAreaByTag(this.messageAreaTag);
const getFromUserName = () => {
return (area && area.realNames) ?
this.client.user.getProperty(UserProps.RealName) || this.client.user.username :
this.client.user.username;
};
let messageBody = this.viewControllers.body.getView(MciViewIds.body.message).getData( { forceLineTerms : this.replyIsAnsi } );
const msgOpts = {
areaTag : this.messageAreaTag,
toUserName : headerValues.to,
fromUserName : this.client.user.username,
fromUserName : getFromUserName(),
subject : headerValues.subject,
// :TODO: don't hard code 1 here:
message : this.viewControllers.body.getView(MciViewIds.body.message).getData( { forceLineTerms : this.replyIsAnsi } ),
};
if(this.isReply()) {
@ -345,11 +352,23 @@ exports.FullScreenEditorModule = exports.getModule = class FullScreenEditorModul
// to packetAnsiMsgEncoding (generally cp437) as various boards
// really don't like ANSI messages in UTF-8 encoding (they should!)
//
msgOpts.meta = { System : { 'explicit_encoding' : _.get(Config(), 'scannerTossers.ftn_bso.packetAnsiMsgEncoding', 'cp437') } };
msgOpts.message = `${ansi.reset()}${ansi.eraseData(2)}${ansi.goto(1,1)}\r\n${ansi.up()}${msgOpts.message}`;
msgOpts.meta = { System : { 'explicit_encoding' : _.get(Config(), 'scannerTossers.ftn_bso.packetAnsiMsgEncoding', 'cp437') } };
messageBody = `${ansi.reset()}${ansi.eraseData(2)}${ansi.goto(1,1)}\r\n${ansi.up()}${messageBody}`;
}
}
//
// Append auto-signature, if enabled for the area & the user has one
//
if(false != area.autoSignatures) {
const sig = this.client.user.getProperty(UserProps.AutoSignature);
if(sig) {
messageBody += `\r\n-- \r\n${sig}`;
}
}
// finally, create the message
msgOpts.message = messageBody;
this.message = new Message(msgOpts);
return cb(null);

View File

@ -28,7 +28,7 @@ function getServer(packageName) {
function startListening(cb) {
const moduleUtil = require('./module_util.js'); // late load so we get Config
async.each( [ 'login', 'content' ], (category, next) => {
async.each( [ 'login', 'content', 'chat' ], (category, next) => {
moduleUtil.loadModulesForCategory(`${category}Servers`, (module, nextModule) => {
const moduleInst = new module.getModule();
try {

View File

@ -40,6 +40,10 @@ module.exports = class LoginServerModule extends ServerModule {
}
handleNewClient(client, clientSock, modInfo) {
clientSock.on('error', err => {
logger.log.warn({ modInfo, error : err.message }, 'Client socket error');
});
//
// Start tracking the client. A session ID aka client ID
// will be established in addNewClient() below.
@ -68,7 +72,7 @@ module.exports = class LoginServerModule extends ServerModule {
});
client.on('error', err => {
logger.log.info({ clientId : client.session.id }, 'Connection error: %s' % err.message);
logger.log.info({ clientId : client.session.id, error : err.message }, 'Connection error');
});
client.on('close', err => {

View File

@ -16,6 +16,7 @@ const { getPredefinedMCIValue } = require('../core/predefined_mci.js');
const async = require('async');
const assert = require('assert');
const _ = require('lodash');
const iconvDecode = require('iconv-lite').decode;
exports.MenuModule = class MenuModule extends PluginModule {
@ -185,7 +186,7 @@ exports.MenuModule = class MenuModule extends PluginModule {
let opts = { cls : true }; // clear screen for first message
async.whilst(
() => this.client.interruptQueue.hasItems(),
(callback) => callback(null, this.client.interruptQueue.hasItems()),
next => {
this.client.interruptQueue.displayNext(opts, err => {
opts = {};
@ -258,6 +259,44 @@ exports.MenuModule = class MenuModule extends PluginModule {
return this.client.menuStack.goto(name, options, cb);
}
gotoMenuOrPrev(name, options, cb) {
this.client.menuStack.goto(name, options, err => {
if(!err) {
if(cb) {
return cb(null);
}
}
return this.prevMenu(cb);
});
}
gotoMenuOrShowMessage(name, message, options, cb) {
if(!cb && _.isFunction(options)) {
cb = options;
options = {};
}
options = options || { clearScreen: true };
this.gotoMenu(name, options, err => {
if(err) {
if(options.clearScreen) {
this.client.term.rawWrite(ansi.resetScreen());
}
this.client.term.write(`${message}\n`);
return this.pausePrompt( () => {
return this.prevMenu(cb);
});
}
if(cb) {
return cb(null);
}
});
}
reload(cb) {
const prevMenu = this.client.menuStack.pop();
prevMenu.instance.leave();
@ -379,7 +418,7 @@ exports.MenuModule = class MenuModule extends PluginModule {
);
}
displayAsset(name, options, cb) {
displayAsset(nameOrData, options, cb) {
if(_.isFunction(options)) {
cb = options;
options = {};
@ -389,10 +428,25 @@ exports.MenuModule = class MenuModule extends PluginModule {
this.client.term.rawWrite(ansi.resetScreen());
}
options = Object.assign( { client : this.client, font : this.menuConfig.config.font }, options );
if(Buffer.isBuffer(nameOrData)) {
const data = iconvDecode(nameOrData, options.encoding || 'cp437');
return theme.displayPreparedArt(
options,
{ data },
(err, artData) => {
if(cb) {
return cb(err, artData);
}
}
);
}
return theme.displayThemedAsset(
name,
nameOrData,
this.client,
Object.assign( { font : this.menuConfig.config.font }, options ),
options,
(err, artData) => {
if(cb) {
return cb(err, artData);
@ -513,7 +567,7 @@ exports.MenuModule = class MenuModule extends PluginModule {
}
setViewText(formName, mciId, text, appendMultiLine) {
const view = this.viewControllers[formName].getView(mciId);
const view = this.getView(formName, mciId);
if(!view) {
return;
}
@ -525,6 +579,11 @@ exports.MenuModule = class MenuModule extends PluginModule {
}
}
getView(formName, id) {
const form = this.viewControllers[formName];
return form && form.getView(id);
}
updateCustomViewTextsWithFilter(formName, startId, fmtObj, options) {
options = options || {};

View File

@ -7,6 +7,9 @@ const {
Errors,
ErrorReasons
} = require('./enig_error.js');
const {
getResolvedSpec
} = require('./menu_util.js');
// deps
const _ = require('lodash');
@ -53,7 +56,7 @@ module.exports = class MenuStack {
next(cb) {
const currentModuleInfo = this.top();
const menuConfig = currentModuleInfo.instance.menuConfig;
const nextMenu = this.client.acs.getConditionalValue(menuConfig.next, 'next');
const nextMenu = getResolvedSpec(this.client, menuConfig.next, 'next');
if(!nextMenu) {
return cb(Array.isArray(menuConfig.next) ?
Errors.MenuStack('No matching condition for "next"', ErrorReasons.NoConditionMatch) :

View File

@ -17,6 +17,7 @@ const _ = require('lodash');
exports.loadMenu = loadMenu;
exports.getFormConfigByIDAndMap = getFormConfigByIDAndMap;
exports.handleAction = handleAction;
exports.getResolvedSpec = getResolvedSpec;
exports.handleNext = handleNext;
function getMenuConfig(client, name, cb) {
@ -172,7 +173,8 @@ function handleAction(client, formData, conf, cb) {
return cb(Errors.MissingParam('Missing config'));
}
const actionAsset = asset.parseAsset(conf.action);
const action = getResolvedSpec(client, conf.action, 'action'); // random/conditionals/etc.
const actionAsset = asset.parseAsset(action);
if(!_.isObject(actionAsset)) {
return cb(Errors.Invalid('Unable to parse "conf.action"'));
}
@ -215,9 +217,38 @@ function handleAction(client, formData, conf, cb) {
}
}
function handleNext(client, nextSpec, conf, cb) {
nextSpec = client.acs.getConditionalValue(nextSpec, 'next'); // handle any conditionals
function getResolvedSpec(client, spec, memberName) {
//
// 'next', 'action', etc. can come in various flavors:
// (1) Simple string:
// next: foo
// (2) Array of objects with 'acs' checks; any object missing 'acs'
// is assumed to be "true":
// next: [
// {
// acs: AR2
// next: foo
// }
// {
// next: baz
// }
// ]
// (3) Simple array of strings. A random selection will be made:
// next: [ "foo", "baz", "fizzbang" ]
//
if(!Array.isArray(spec)) {
return spec; // (1) simple string, as-is
}
if(_.isObject(spec[0])) {
return client.acs.getConditionalValue(spec, memberName); // (2) ACS conditionals
}
return spec[Math.floor(Math.random() * spec.length)]; // (3) random
}
function handleNext(client, nextSpec, conf, cb) {
nextSpec = getResolvedSpec(client, nextSpec, 'next');
const nextAsset = asset.getAssetWithShorthand(nextSpec, 'menu');
// :TODO: getAssetWithShorthand() can return undefined - handle it!

View File

@ -236,16 +236,18 @@ module.exports = class Message {
filter.uuids - use with resultType='id'
filter.ids - use with resultType='uuid'
filter.toUserName
filter.fromUserName
filter.toUserName - string|Array(string)
filter.fromUserName - string|Array(string)
filter.replyToMessageId
filter.operator = (AND)|OR
filter.newerThanTimestamp - may not be used with |date|
filter.date - moment object - may not be used with |newerThanTimestamp|
filter.newerThanMessageId
filter.areaTag - note if you want by conf, send in all areas for a conf
*filter.metaTuples - {category, name, value}
filter.metaTuples - [ {category, name, value} ]
filter.terms - FTS search
@ -267,6 +269,7 @@ module.exports = class Message {
filter.resultType = filter.resultType || 'id';
filter.extraFields = filter.extraFields || [];
filter.operator = filter.operator || 'AND';
if('messageList' === filter.resultType) {
filter.extraFields = _.uniq(filter.extraFields.concat(
@ -296,9 +299,9 @@ module.exports = class Message {
let sqlOrderBy;
let sqlWhere = '';
function appendWhereClause(clause) {
function appendWhereClause(clause, op) {
if(sqlWhere) {
sqlWhere += ' AND ';
sqlWhere += ` ${op || filter.operator} `;
} else {
sqlWhere += ' WHERE ';
}
@ -345,7 +348,7 @@ module.exports = class Message {
}
// explicit exclude of Private
appendWhereClause(`m.area_tag != "${Message.WellKnownAreaTags.Private}"`);
appendWhereClause(`m.area_tag != "${Message.WellKnownAreaTags.Private}"`, 'AND');
}
if(_.isNumber(filter.replyToMessageId)) {
@ -353,8 +356,18 @@ module.exports = class Message {
}
[ 'toUserName', 'fromUserName' ].forEach(field => {
if(_.isString(filter[field]) && filter[field].length > 0) {
appendWhereClause(`m.${_.snakeCase(field)} LIKE "${sanitizeString(filter[field])}"`);
let val = filter[field];
if(!val) {
return; // next item
}
if(_.isString(val)) {
val = [ val ];
}
if(Array.isArray(val)) {
val = '(' + val.map(v => {
return `m.${_.snakeCase(field)} LIKE "${sanitizeString(v)}"`;
}).join(' OR ') + ')';
appendWhereClause(val);
}
});
@ -380,6 +393,21 @@ module.exports = class Message {
);
}
if(Array.isArray(filter.metaTuples)) {
let sub = [];
filter.metaTuples.forEach(mt => {
sub.push(`(meta_category = "${mt.category}" AND meta_name = "${mt.name}" AND meta_value = "${sanitizeString(mt.value)}")`);
});
sub = sub.join(` ${filter.operator} `);
appendWhereClause(
`m.message_id IN (
SELECT message_id
FROM message_meta
WHERE ${sub}
)`
);
}
sql += `${sqlWhere} ${sqlOrderBy}`;
if(_.isNumber(filter.limit)) {
@ -812,7 +840,7 @@ module.exports = class Message {
} else {
const QUOTE_RE = /^ ((?:[A-Za-z0-9]{2}> )+(?:[A-Za-z0-9]{2}>)*) */;
const quoted = [];
const input = _.trimEnd(this.message).replace(/\b/g, '');
const input = _.trimEnd(this.message).replace(/\x08/g, '');
// find *last* tearline
let tearLinePos = this.getTearLinePosition(input);

View File

@ -26,10 +26,15 @@ exports.getAvailableMessageAreasByConfTag = getAvailableMessageAreasByConfTag;
exports.getSortedAvailMessageAreasByConfTag = getSortedAvailMessageAreasByConfTag;
exports.getDefaultMessageConferenceTag = getDefaultMessageConferenceTag;
exports.getDefaultMessageAreaTagByConfTag = getDefaultMessageAreaTagByConfTag;
exports.getSuitableMessageConfAndAreaTags = getSuitableMessageConfAndAreaTags;
exports.getMessageConferenceByTag = getMessageConferenceByTag;
exports.getMessageAreaByTag = getMessageAreaByTag;
exports.changeMessageConference = changeMessageConference;
exports.changeMessageArea = changeMessageArea;
exports.hasMessageConfAndAreaRead = hasMessageConfAndAreaRead;
exports.hasMessageConfAndAreaWrite = hasMessageConfAndAreaWrite;
exports.filterMessageAreaTagsByReadACS = filterMessageAreaTagsByReadACS;
exports.filterMessageListByReadACS = filterMessageListByReadACS;
exports.tempChangeMessageConfAndArea = tempChangeMessageConfAndArea;
exports.getMessageListForArea = getMessageListForArea;
exports.getNewMessageCountInAreaForUser = getNewMessageCountInAreaForUser;
@ -185,7 +190,10 @@ function getDefaultMessageAreaTagByConfTag(client, confTag, disableAcsCheck) {
}
}
defaultArea = _.findKey(areaPool, (area) => {
defaultArea = _.findKey(areaPool, (area, areaTag) => {
if(Message.isPrivateAreaTag(areaTag)) {
return false;
}
return (true === disableAcsCheck || client.acs.hasMessageAreaRead(area));
});
@ -193,8 +201,47 @@ function getDefaultMessageAreaTagByConfTag(client, confTag, disableAcsCheck) {
}
}
function getSuitableMessageConfAndAreaTags(client) {
//
// Attempts to get a pair of suitable conf/area tags:
// * Where the client/user has proper ACS to both
// * Try to use defaults where possible
// * If default conf/area is not an option, use any
// that pass ACS.
// * Returns a tuple [confTag, areaTag]; areaTag
// and possibly confTag may both be set to '' if
// if we fail to find something.
//
let confTag = getDefaultMessageConferenceTag(client);
if(!confTag) {
return ['', '']; // can't have an area without a conf
}
let areaTag = getDefaultMessageAreaTagByConfTag(client, confTag);
if(!areaTag) {
// OK, perhaps *any* area in *any* conf?
_.forEach(Config().messageConferences, (conf, ct) => {
if(!client.acs.hasMessageConfRead(conf)) {
return;
}
_.forEach(conf.areas, (area, at) => {
if(!_.includes(Message.WellKnownAreaTags, at) && client.acs.hasMessageAreaRead(area)) {
confTag = ct;
areaTag = at;
return false; // stop inner iteration
}
});
if(areaTag) {
return false; // stop iteration
}
});
}
return [confTag, areaTag || ''];
}
function getMessageConferenceByTag(confTag) {
return Config().messageConferences[confTag];
return Object.assign({ confTag }, Config().messageConferences[confTag]);
}
function getMessageConfTagByAreaTag(areaTag) {
@ -210,16 +257,22 @@ function getMessageAreaByTag(areaTag, optionalConfTag) {
// :TODO: this could be cached
if(_.isString(optionalConfTag)) {
if(_.has(confs, [ optionalConfTag, 'areas', areaTag ])) {
return confs[optionalConfTag].areas[areaTag];
return Object.assign(
{
areaTag,
confTag : optionalConfTag,
},
confs[optionalConfTag].areas[areaTag]
);
}
} else {
//
// No confTag to work with - we'll have to search through them all
//
let area;
_.forEach(confs, (v) => {
if(_.has(v, [ 'areas', areaTag ])) {
area = v.areas[areaTag];
_.forEach(confs, (conf, confTag) => {
if(_.has(conf, [ 'areas', areaTag ])) {
area = Object.assign({ areaTag, confTag }, conf.areas[areaTag]);
return false; // stop iteration
}
});
@ -350,6 +403,56 @@ function changeMessageArea(client, areaTag, cb) {
changeMessageAreaWithOptions(client, areaTag, { persist : true }, cb);
}
function hasMessageConfAndAreaRead(client, areaOrTag) {
if(_.isString(areaOrTag)) {
areaOrTag = getMessageAreaByTag(areaOrTag) || {};
}
const conf = getMessageConferenceByTag(areaOrTag.confTag);
return client.acs.hasMessageConfRead(conf) && client.acs.hasMessageAreaRead(areaOrTag);
}
function hasMessageConfAndAreaWrite(client, areaOrTag) {
if(_.isString(areaOrTag)) {
areaOrTag = getMessageAreaByTag(areaOrTag) || {};
}
const conf = getMessageConferenceByTag(areaOrTag.confTag);
return client.acs.hasMessageConfWrite(conf) && client.acs.hasMessageAreaWrite(areaOrTag);
}
function filterMessageAreaTagsByReadACS(client, areaTags) {
if(!Array.isArray(areaTags)) {
areaTags = [ areaTags ];
}
return areaTags.filter( areaTag => {
const area = getMessageAreaByTag(areaTag);
return hasMessageConfAndAreaRead(client, area);
});
}
function filterMessageListByReadACS(client, messageList) {
//
// Filter out messages belonging to conf/areas the user
// doesn't have access to.
//
// Keep a cache around for quick lookup.
const acsCache = new Map(); // areaTag:boolean
return messageList.filter(msg => {
let cached = acsCache.get(msg.areaTag);
if(false === cached) {
return false;
}
if(true === cached) {
return true;
}
cached = hasMessageConfAndAreaRead(client, msg.areaTag);
acsCache.set(msg.areaTag, cached);
return cached;
});
}
function getNewMessageCountInAreaForUser(userId, areaTag, cb) {
getMessageAreaLastReadId(userId, areaTag, (err, lastMessageId) => {
lastMessageId = lastMessageId || 0;
@ -404,8 +507,14 @@ function getMessageListForArea(client, areaTag, filter, cb)
Object.assign(filter, { areaTag } );
}
if(client) {
if(!hasMessageConfAndAreaRead(client, areaTag)) {
return cb(null, []);
}
}
if(Message.isPrivateAreaTag(areaTag)) {
filter.privateTagUserId = client.user.userId;
filter.privateTagUserId = client ? client.user.userId : 'INVALID_USER_ID';
}
return Message.findMessages(filter, cb);

View File

@ -7,6 +7,8 @@ const {
getSortedAvailMessageConferences,
getAvailableMessageAreasByConfTag,
getSortedAvailMessageAreasByConfTag,
hasMessageConfAndAreaRead,
filterMessageListByReadACS,
} = require('./message_area.js');
const Errors = require('./enig_error.js').Errors;
const Message = require('./message.js');
@ -101,6 +103,14 @@ exports.getModule = class MessageBaseSearch extends MenuModule {
limit : 2048, // :TODO: best way to handle this? we should probably let the user know if some results are returned
};
const returnNoResults = () => {
return this.gotoMenu(
this.menuConfig.config.noResultsMenu || 'messageSearchNoResults',
{ menuFlags : [ 'popParent' ] },
cb
);
};
if(isAdvanced) {
filter.toUserName = value.toUserName;
filter.fromUserName = value.fromUserName;
@ -113,7 +123,11 @@ exports.getModule = class MessageBaseSearch extends MenuModule {
(area, areaTag) => areaTag
);
} else if(value.areaTag) {
filter.areaTag = value.areaTag; // specific conf + area
if(hasMessageConfAndAreaRead(this.client, value.areaTag)) {
filter.areaTag = value.areaTag; // specific conf + area
} else {
return returnNoResults();
}
}
}
@ -122,12 +136,14 @@ exports.getModule = class MessageBaseSearch extends MenuModule {
return cb(err);
}
// don't include results without ACS -- if the user searched by
// explicit conf/area tag, we should have already filtered (above)
if(!value.confTag && !value.areaTag) {
messageList = filterMessageListByReadACS(this.client, messageList);
}
if(0 === messageList.length) {
return this.gotoMenu(
this.menuConfig.config.noResultsMenu || 'messageSearchNoResults',
{ menuFlags : [ 'popParent' ] },
cb
);
return returnNoResults();
}
const menuOpts = {

View File

@ -117,6 +117,7 @@ function getModulePaths() {
config.paths.mods,
config.paths.loginServers,
config.paths.contentServers,
config.paths.chatServers,
config.paths.scannerTossers,
];
}

575
core/mrc.js Normal file
View File

@ -0,0 +1,575 @@
/* jslint node: true */
'use strict';
// ENiGMA½
const Log = require('./logger.js').log;
const { MenuModule } = require('./menu_module.js');
const {
pipeToAnsi,
stripMciColorCodes
} = require('./color_codes.js');
const stringFormat = require('./string_format.js');
const StringUtil = require('./string_util.js');
const Config = require('./config.js').get;
// deps
const _ = require('lodash');
const async = require('async');
const net = require('net');
const moment = require('moment');
exports.moduleInfo = {
name : 'MRC Client',
desc : 'Connects to an MRC chat server',
author : 'RiPuk',
packageName : 'codes.l33t.enigma.mrc.client',
// Whilst this module was put together by me (RiPuk), it should be noted that a lot of the ideas (and even some code snippets) were
// borrowed from the Synchronet implementation of MRC by echicken. So...thanks, your code was very helpful in putting this together.
// Source at http://cvs.synchro.net/cgi-bin/viewcvs.cgi/xtrn/mrc/.
};
const FormIds = {
mrcChat : 0,
};
const MciViewIds = {
mrcChat : {
chatLog : 1,
inputArea : 2,
roomName : 3,
roomTopic : 4,
mrcUsers : 5,
mrcBbses : 6,
customRangeStart : 20, // 20+ = customs
}
};
// TODO: this is a bit shit, could maybe do it with an ansi instead
const helpText = `
|15General Chat|08:
|03/|11rooms |08& |03/|11join |03<room> |08- |07List all or join a room
|03/|11pm |03<user> <message> |08- |07Send a private message
----
|03/|11whoon |08- |07Who's on what BBS
|03/|11chatters |08- |07Who's in what room
|03/|11clear |08- |07Clear back buffer
|03/|11topic |03<message> |08- |07Set the room topic
|03/|11bbses |08& |03/|11info <id> |08- |07Info about BBS's connected
|03/|11meetups |08- |07Info about MRC MeetUps
---
|03/|11l33t |03<your message> |08- |07l337 5p34k
|03/|11kewl |03<your message> |08- |07BBS KeWL SPeaK
|03/|11rainbow |03<your message> |08- |07Crazy rainbow text
`;
exports.getModule = class mrcModule extends MenuModule {
constructor(options) {
super(options);
this.log = Log.child( { module : 'MRC' } );
this.config = Object.assign({}, _.get(options, 'menuConfig.config'), { extraArgs : options.extraArgs });
this.config.maxScrollbackLines = this.config.maxScrollbackLines || 500;
this.state = {
socket: '',
alias: this.client.user.username,
room: '',
room_topic: '',
nicks: [],
lastSentMsg : {}, // used for latency est.
};
this.customFormatObj = {
roomName : '',
roomTopic : '',
roomUserCount : 0,
userCount : 0,
boardCount : 0,
roomCount : 0,
latencyMs : 0,
activityLevel : 0,
activityLevelIndicator : ' ',
};
this.menuMethods = {
sendChatMessage : (formData, extraArgs, cb) => {
const inputAreaView = this.viewControllers.mrcChat.getView(MciViewIds.mrcChat.inputArea);
const inputData = inputAreaView.getData();
this.processOutgoingMessage(inputData);
inputAreaView.clearText();
return cb(null);
},
movementKeyPressed : (formData, extraArgs, cb) => {
const bodyView = this.viewControllers.mrcChat.getView(MciViewIds.mrcChat.chatLog);
switch(formData.key.name) {
case 'down arrow' : bodyView.scrollDocumentUp(); break;
case 'up arrow' : bodyView.scrollDocumentDown(); break;
case 'page up' : bodyView.keyPressPageUp(); break;
case 'page down' : bodyView.keyPressPageDown(); break;
}
this.viewControllers.mrcChat.switchFocus(MciViewIds.mrcChat.inputArea);
return cb(null);
},
quit : (formData, extraArgs, cb) => {
return this.prevMenu(cb);
},
clearMessages : (formData, extraArgs, cb) => {
this.clearMessages();
return cb(null);
}
};
}
mciReady(mciData, cb) {
super.mciReady(mciData, err => {
if(err) {
return cb(err);
}
async.series(
[
(callback) => {
return this.prepViewController('mrcChat', FormIds.mrcChat, mciData.menu, callback);
},
(callback) => {
return this.validateMCIByViewIds('mrcChat', [ MciViewIds.mrcChat.chatLog, MciViewIds.mrcChat.inputArea ], callback);
},
(callback) => {
const connectOpts = {
port : _.get(Config(), 'chatServers.mrc.multiplexerPort', 5000),
host : 'localhost',
};
// connect to multiplexer
this.state.socket = net.createConnection(connectOpts, () => {
this.client.once('end', () => {
this.quitServer();
});
// handshake with multiplexer
this.state.socket.write(`--DUDE-ITS--|${this.state.alias}\n`);
this.clientConnect();
// send register to central MRC and get stats every 60s
this.heartbeat = setInterval( () => {
this.sendHeartbeat();
this.sendServerMessage('STATS');
}, 60000);
// override idle logout seconds if configured
const idleLogoutSeconds = parseInt(this.config.idleLogoutSeconds);
if(0 === idleLogoutSeconds) {
this.log.debug('Temporary disable idle monitor due to config');
this.client.stopIdleMonitor();
} else if (!isNaN(idleLogoutSeconds) && idleLogoutSeconds >= 60) {
this.log.debug( { idleLogoutSeconds }, 'Temporary override idle logout seconds due to config');
this.client.overrideIdleLogoutSeconds(idleLogoutSeconds);
}
});
// when we get data, process it
this.state.socket.on('data', data => {
data = data.toString();
this.processReceivedMessage(data);
});
this.state.socket.once('error', err => {
this.log.warn( { error : err.message }, 'MRC multiplexer socket error' );
this.state.socket.destroy();
delete this.state.socket;
// bail with error - fall back to prev menu
return callback(err);
});
return(callback);
}
],
err => {
return cb(err);
}
);
});
}
leave() {
this.quitServer();
// restore idle monitor to previous state
this.log.debug('Restoring idle monitor to previous state');
this.client.restoreIdleLogoutSeconds();
this.client.startIdleMonitor();
return super.leave();
}
quitServer() {
clearInterval(this.heartbeat);
if(this.state.socket) {
this.sendServerMessage('LOGOFF');
this.state.socket.destroy();
delete this.state.socket;
}
}
/**
* Adds a message to the chat log on screen
*/
addMessageToChatLog(message) {
if(!Array.isArray(message)) {
message = [ message ];
}
message.forEach(msg => {
const chatLogView = this.viewControllers.mrcChat.getView(MciViewIds.mrcChat.chatLog);
const messageLength = stripMciColorCodes(msg).length;
const chatWidth = chatLogView.dimens.width;
let padAmount = 0;
let spaces = 2;
if (messageLength > chatWidth) {
padAmount = chatWidth - (messageLength % chatWidth) - spaces;
} else {
padAmount = chatWidth - messageLength - spaces;
}
if (padAmount < 0) padAmount = 0;
const padding = ' |00' + ' '.repeat(padAmount);
chatLogView.addText(pipeToAnsi(msg + padding));
if(chatLogView.getLineCount() > this.config.maxScrollbackLines) {
chatLogView.deleteLine(0);
}
});
}
/**
* Processes data received from the MRC multiplexer
*/
processReceivedMessage(blob) {
blob.split('\n').forEach( message => {
try {
message = JSON.parse(message);
} catch (e) {
return;
}
if (message.from_user == 'SERVER') {
const params = message.body.split(':');
switch (params[0]) {
case 'BANNER':
this.addMessageToChatLog(params[1].replace(/^\s+/, ''));
break;
case 'ROOMTOPIC':
this.setText(MciViewIds.mrcChat.roomName, `#${params[1]}`);
this.setText(MciViewIds.mrcChat.roomTopic, params[2]);
this.customFormatObj.roomName = params[1];
this.customFormatObj.roomTopic = params[2];
this.updateCustomViews();
this.state.room = params[1];
break;
case 'USERLIST':
this.state.nicks = params[1].split(',');
this.customFormatObj.roomUserCount = this.state.nicks.length;
this.updateCustomViews();
break;
case 'STATS': {
const [
boardCount,
roomCount,
userCount,
activityLevel
] = params[1].split(' ').map(v => parseInt(v));
const activityLevelIndicator = this.getActivityLevelIndicator(activityLevel);
Object.assign(
this.customFormatObj,
{
boardCount, roomCount, userCount,
activityLevel, activityLevelIndicator
}
);
this.setText(MciViewIds.mrcChat.mrcUsers, userCount);
this.setText(MciViewIds.mrcChat.mrcBbses, boardCount);
this.updateCustomViews();
break;
}
default:
this.addMessageToChatLog(message.body);
break;
}
} else {
if(message.body === this.state.lastSentMsg.msg) {
this.customFormatObj.latencyMs =
moment.duration(moment().diff(this.state.lastSentMsg.time)).asMilliseconds();
delete this.state.lastSentMsg.msg;
}
if (message.to_room == this.state.room) {
// if we're here then we want to show it to the user
const currentTime = moment().format(this.client.currentTheme.helpers.getTimeFormat());
this.addMessageToChatLog('|08' + currentTime + '|00 ' + message.body + '|00');
}
}
this.viewControllers.mrcChat.switchFocus(MciViewIds.mrcChat.inputArea);
});
}
getActivityLevelIndicator(level) {
let indicators = this.config.activityLevelIndicators;
if(!Array.isArray(indicators) || indicators.length < level + 1) {
indicators = [ ' ', '░', '▒', '▓' ];
}
return indicators[level];
}
setText(mciId, text) {
return this.setViewText('mrcChat', mciId, text);
}
updateCustomViews() {
return this.updateCustomViewTextsWithFilter(
'mrcChat',
MciViewIds.mrcChat.customRangeStart,
this.customFormatObj
);
}
/**
* Receives the message input from the user and does something with it based on what it is
*/
processOutgoingMessage(message, to_user) {
if (message.startsWith('/')) {
this.processSlashCommand(message);
} else {
if (message == '') {
// don't do anything if message is blank, just update stats
this.sendServerMessage('STATS');
return;
}
// else just format and send
const textFormatObj = {
fromUserName : this.state.alias,
toUserName : to_user,
message : message
};
const messageFormat =
this.config.messageFormat ||
'|00|10<|02{fromUserName}|10>|00 |03{message}|00';
const privateMessageFormat =
this.config.outgoingPrivateMessageFormat ||
'|00|10<|02{fromUserName}|10|14->|02{toUserName}>|00 |03{message}|00';
let formattedMessage = '';
if (to_user == undefined) {
// normal message
formattedMessage = stringFormat(messageFormat, textFormatObj);
} else {
// pm
formattedMessage = stringFormat(privateMessageFormat, textFormatObj);
}
try {
this.state.lastSentMsg = {
msg : formattedMessage,
time : moment(),
};
this.sendMessageToMultiplexer(to_user || '', '', this.state.room, formattedMessage);
} catch(e) {
this.client.log.warn( { error : e.message }, 'MRC error');
}
}
}
/**
* Processes a message that begins with a slash
*/
processSlashCommand(message) {
const cmd = message.split(' ');
cmd[0] = cmd[0].substr(1).toLowerCase();
switch (cmd[0]) {
case 'pm':
this.processOutgoingMessage(cmd[2], cmd[1]);
break;
case 'rainbow': {
// this is brutal, but i love it
const line = message.replace(/^\/rainbow\s/, '').split(' ').reduce(function (a, c) {
const cc = Math.floor((Math.random() * 31) + 1).toString().padStart(2, '0');
a += `|${cc}${c}|00 `;
return a;
}, '').substr(0, 140).replace(/\\s\|\d*$/, '');
this.processOutgoingMessage(line);
break;
}
case 'l33t':
this.processOutgoingMessage(StringUtil.stylizeString(message.substr(6), 'l33t'));
break;
case 'kewl': {
const text_modes = Array('f','v','V','i','M');
const mode = text_modes[Math.floor(Math.random() * text_modes.length)];
this.processOutgoingMessage(StringUtil.stylizeString(message.substr(6), mode));
break;
}
case 'whoon':
this.sendServerMessage('WHOON');
break;
case 'motd':
this.sendServerMessage('MOTD');
break;
case 'meetups':
this.sendServerMessage('MEETUPS');
break;
case 'bbses':
this.sendServerMessage('CONNECTED');
break;
case 'topic':
this.sendServerMessage(`NEWTOPIC:${this.state.room}:${message.substr(7)}`);
break;
case 'info':
this.sendServerMessage(`INFO ${cmd[1]}`);
break;
case 'join':
this.joinRoom(cmd[1]);
break;
case 'chatters':
this.sendServerMessage('CHATTERS');
break;
case 'rooms':
this.sendServerMessage('LIST');
break;
case 'quit' :
return this.prevMenu();
case 'clear':
this.clearMessages();
break;
case '?':
this.addMessageToChatLog(helpText.split(/\n/g));
break;
default:
break;
}
// just do something to get the cursor back to the right place ¯\_(ツ)_/¯
// :TODO: fix me!
this.sendServerMessage('STATS');
}
clearMessages() {
const chatLogView = this.viewControllers.mrcChat.getView(MciViewIds.mrcChat.chatLog);
chatLogView.setText('');
}
/**
* Creates a json object, stringifies it and sends it to the MRC multiplexer
*/
sendMessageToMultiplexer(to_user, to_site, to_room, body) {
const message = {
to_user,
to_site,
to_room,
body,
from_user : this.state.alias,
from_room : this.state.room,
};
if(this.state.socket) {
this.state.socket.write(JSON.stringify(message) + '\n');
}
}
/**
* Sends an MRC 'server' message
*/
sendServerMessage(command, to_site) {
Log.debug({ module: 'mrc', command: command }, 'Sending server command');
this.sendMessageToMultiplexer('SERVER', to_site || '', this.state.room, command);
}
/**
* Sends a heartbeat to the MRC server
*/
sendHeartbeat() {
this.sendServerMessage('IAMHERE');
}
/**
* Joins a room, unsurprisingly
*/
joinRoom(room) {
// room names are displayed with a # but referred to without. confusing.
room = room.replace(/^#/, '');
this.state.room = room;
this.sendServerMessage(`NEWROOM:${this.state.room}:${room}`);
this.sendServerMessage('USERLIST');
}
/**
* Things that happen when a local user connects to the MRC multiplexer
*/
clientConnect() {
this.sendServerMessage('MOTD');
this.joinRoom('lobby');
this.sendServerMessage('STATS');
this.sendHeartbeat();
}
};

View File

@ -4,6 +4,9 @@
const FullScreenEditorModule = require('./fse.js').FullScreenEditorModule;
const persistMessage = require('./message_area.js').persistMessage;
const UserProps = require('./user_property.js');
const {
hasMessageConfAndAreaWrite,
} = require('./message_area.js');
const _ = require('lodash');
const async = require('async');
@ -59,12 +62,25 @@ exports.getModule = class AreaPostFSEModule extends FullScreenEditorModule {
}
enter() {
if(_.isString(this.client.user.properties[UserProps.MessageAreaTag]) &&
!_.isString(this.messageAreaTag))
{
this.messageAreaTag = this.client.user.properties[UserProps.MessageAreaTag];
}
this.messageAreaTag =
this.messageAreaTag ||
this.client.user.getProperty(UserProps.MessageAreaTag);
super.enter();
}
initSequence() {
if(!hasMessageConfAndAreaWrite(this.client, this.messageAreaTag)) {
const noAcsMenu =
this.menuConfig.config.messageBasePostMessageNoAccess ||
'messageBasePostMessageNoAccess';
return this.gotoMenuOrShowMessage(
noAcsMenu,
'You do not have the proper access to post here!',
);
}
super.initSequence();
}
};

View File

@ -227,7 +227,7 @@ exports.getModule = class MessageListModule extends MessageAreaConfTempSwitcher(
return callback(err);
});
},
function getLastReadMesageId(callback) {
function getLastReadMessageId(callback) {
// messageList entries can contain |isNew| if they want to be considered new
if(configProvidedMessageList) {
self.lastReadId = 0;

65
core/my_messages.js Normal file
View File

@ -0,0 +1,65 @@
/* jslint node: true */
'use strict';
// ENiGMA½
const MenuModule = require('./menu_module.js').MenuModule;
const Message = require('./message.js');
const UserProps = require('./user_property.js');
const {
filterMessageListByReadACS
} = require('./message_area.js');
exports.moduleInfo = {
name : 'My Messages',
desc : 'Finds messages addressed to the current user.',
author : 'NuSkooler',
};
exports.getModule = class MyMessagesModule extends MenuModule {
constructor(options) {
super(options);
}
initSequence() {
const filter = {
toUserName : [ this.client.user.username, this.client.user.getProperty(UserProps.RealName) ],
sort : 'modTimestamp',
resultType : 'messageList',
limit : 1024 * 16, // we want some sort of limit...
};
Message.findMessages(filter, (err, messageList) => {
if(err) {
this.client.log.warn( { error : err.message }, 'Error finding messages addressed to current user');
return this.prevMenu();
}
// don't include results without ACS
this.messageList = filterMessageListByReadACS(this.client, messageList);
this.finishedLoading();
});
}
finishedLoading() {
if(!this.messageList || 0 === this.messageList.length) {
return this.gotoMenu(
this.menuConfig.config.noResultsMenu || 'messageSearchNoResults',
{ menuFlags : [ 'popParent' ] }
);
}
const menuOpts = {
extraArgs : {
messageList : this.messageList,
noUpdateLastReadId : true
},
menuFlags : [ 'popParent' ],
};
return this.gotoMenu(
this.menuConfig.config.messageListMenu || 'messageAreaMessageList',
menuOpts
);
}
};

View File

@ -209,7 +209,7 @@ function scanFileAreaForChanges(areaInfo, options, cb) {
async.series(
[
function quickCheck(next) {
if(!options.quick) {
if(options['full']) {
return next(null);
}
@ -229,6 +229,16 @@ function scanFileAreaForChanges(areaInfo, options, cb) {
areaTag : areaInfo.areaTag,
storageTag : storageLoc.storageTag
},
(stepInfo, next) => {
if(argv.verbose) {
if(stepInfo.error) {
console.error(` error: ${stepInfo.error}`);
} else {
console.info(` processing: ${stepInfo.step}`);
}
}
return next(null);
},
(err, fileEntry, dupeEntries) => {
if(err) {
console.info(`Error: ${err.message}`);
@ -476,8 +486,8 @@ function scanFileAreas() {
options.tags = tags.split(',');
}
options.descFile = argv['desc-file']; // --desc-file or --desc-file PATH
options.quick = argv.quick;
options.descFile = argv['desc-file']; // --desc-file or --desc-file PATH
options['full'] = argv.full;
options.areaAndStorageInfo = getAreaAndStorage(argv._.slice(2));

View File

@ -9,113 +9,174 @@ exports.getHelpFor = getHelpFor;
const usageHelp = exports.USAGE_HELP = {
General :
`usage: oputil.js [--version] [--help]
<command> [<args>]
<command> [<arguments>]
global args:
-c, --config PATH specify config path (${getDefaultConfigPath()})
-n, --no-prompt assume defaults/don't prompt for input where possible
Global arguments:
-c, --config PATH Specify config path (default is ${getDefaultConfigPath()})
-n, --no-prompt Assume defaults (don't prompt for input where possible)
--verbose Verbose output, where applicable
commands:
user user utilities
config config file management
fb file base management
mb message base management
Commands:
user User management
config Configuration management
fb File base management
mb Message base management
`,
User :
`usage: oputil.js user <action> [<args>]
`usage: oputil.js user <action> [<arguments>]
actions:
info USERNAME display information about a user
pw USERNAME PASSWORD set a user's password
aliases: password, passwd
rm USERNAME permanently removes user from system
aliases: remove, delete, del
activate USERNAME set status to active
deactivate USERNAME set status to inactive
disable USERNAME set status to disabled
lock USERNAME set status to locked
group USERNAME [+|-]GROUP adds (+) or removes (-) user from a group
Actions:
info USERNAME Display information about a user
pw USERNAME PASSWORD Set a user's password
(passwd|password)
rm USERNAME Permanently removes user from system
(del|delete|remove)
rename USERNAME NEWNAME Rename a user
(mv)
2fa-otp USERNAME SPEC Enable 2FA/OTP for the user
(otp)
The system supports various implementations of Two Factor Authentication (2FA)
One Time Password (OTP) authentication.
Valid specs:
disable : Removes 2FA/OTP from the user
google : Google Authenticator
hotp : HMAC-Based One-Time Password Algorithm (RFC-4266)
totp : Time-Based One-Time Password Algorithm (RFC-6238)
activate USERNAME Set a user's status to "active"
deactivate USERNAME Set a user's status to "inactive"
disable USERNAME Set a user's status to "disabled"
lock USERNAME Set a user's status to "locked"
group USERNAME [+|-]GROUP Adds (+) or removes (-) user from a group
info arguments:
--security Include security information in output
2fa-otp arguments:
--qr-type TYPE Specify QR code type
Valid QR types:
ascii : Plain ASCII (default)
data : HTML data URL
img : HTML image tag
svg : SVG image
--out PATH Path to write QR code to. defaults to stdout
`,
Config :
`usage: oputil.js config <action> [<args>]
`usage: oputil.js config <action> [<arguments>]
actions:
new generate a new/initial configuration
cat cat current configuration to stdout
Actions:
new Generate a new / default configuration
cat args:
--no-color disable color
--no-comments strip any comments
cat Write current configuration to stdout
cat arguments:
--no-color Disable color
--no-comments Strip any comments
`,
FileBase :
`usage: oputil.js fb <action> [<args>]
`usage: oputil.js fb <action> [<arguments>]
actions:
scan AREA_TAG[@STORAGE_TAG] scan specified area
may also contain optional GLOB as last parameter,
for example: scan some_area *.zip
Actions:
scan AREA_TAG[@STORAGE_TAG] Scan specified area
info CRITERIA display information about areas and/or files
matching CRITERIA.
May contain optional GLOB as last parameter.
Example: ./oputil.js fb scan d0pew4r3z *.zip
mv SRC [SRC...] DST move entry(s) from SRC to DST
SRC: FILENAME_WC|SHA|FILE_ID|AREA_TAG[@STORAGE_TAG]
DST: AREA_TAG[@STORAGE_TAG]
info CRITERIA Display information about areas and/or files
rm SRC [SRC...] remove entry(s) from the system matching SRC
SRC: FILENAME_WC|SHA|FILE_ID|AREA_TAG[@STORAGE_TAG]
desc CRITERIA sets a new file description for file base entry
matching CRITERIA. Launches an external editor using
$VISUAL, $EDITOR, or vim/notepad.
import-areas FILEGATE.ZXX import file base areas using FileGate RAID type format
mv SRC [SRC...] DST Move matching entry(s)
(move)
scan args:
--tags TAG1,TAG2,... specify tag(s) to assign to discovered entries
Source may be any of the following:
- Filename including '*' wildcards
- SHA-1
- File ID
- Area tag with optional @storageTag suffix
Destination is area tag with optional @storageTag suffix
--desc-file [PATH] prefer file descriptions from supplied path over other
other sources such as FILE_ID.DIZ. Path must point to
a valid FILES.BBS or DESCRIPT.ION file.
--update attempt to update information for existing entries
--quick perform quick scan
rm SRC [SRC...] Remove entry(s) from the system
(del|delete|remove)
info args:
--show-desc display short description, if any
Source may be any of the following:
- Filename including '*' wildcards
- SHA-1
- File ID
- Area tag with optional @storageTag suffix
remove args:
--phys-file also remove underlying physical file
desc CRITERIA Updates an file base entry's description
import-areas args:
--type TYPE sets import areas type. valid options are "zxx" or "na"
--create-dirs create backing storage directories
Launches an external editor using $VISUAL, $EDITOR, or vim/notepad.
import-areas FILEGATE.ZXX Import file base areas using FileGate RAID type format
scan arguments:
--tags TAG1,TAG2,... Specify hashtag(s) to assign to discovered entries
--desc-file [PATH] Prefer file descriptions from supplied input file
If a file description can be found in the supplied input file, prefer that description
over other sources such related FILE_ID.DIZ. Path must point to a valid FILES.BBS or
DESCRIPT.ION file.
--update Attempt to update information for existing entries
--full Perform a full scan (default is quick)
info arguments:
--show-desc Display short description, if any
remove arguments:
--phys-file Also remove underlying physical file
import-areas arguments:
--type TYPE Sets import areas type
Valid types are are "zxx" or "na".
--create-dirs Also create backing storage directories
`,
FileOpsInfo :
`
general information:
AREA_TAG[@STORAGE_TAG] can specify an area tag and optionally, a storage specific tag
example: retro@bbs
General Information:
Generally an area tag can also include an optional storage tag. For example, the
area of 'bbswarez' stored using 'bbswarez_main': bbswarez@bbswarez_main
CRITERIA file base entry criteria. in general, can be AREA_TAG, SHA,
FILE_ID, or FILENAME_WC.
When performing an initial import of a large area or storage backing, --full
is the best option. If re-scanning an area for updates a standard / quick scan is
generally good enough.
FILENAME_WC filename with * and ? wildcard support. may match 0:n entries
SHA full or partial SHA-256
FILE_ID a file identifier. see file.sqlite3
File ID's are those found in file.sqlite3.
`,
MessageBase :
`usage: oputil.js mb <action> [<args>]
`usage: oputil.js mb <action> [<arguments>]
actions:
areafix CMD1 CMD2 ... ADDR sends an AreaFix NetMail to ADDR with the supplied command(s)
one or more commands may be supplied. commands that are multi
part such as "%COMPRESS ZIP" should be quoted.
import-areas PATH import areas using fidonet *.NA or AREAS.BBS file from PATH
Actions:
areafix CMD1 CMD2 ... ADDR Sends an AreaFix NetMail
import-areas args:
--conf CONF_TAG conference tag in which to import areas
--network NETWORK network name/key to associate FTN areas
--uplinks UL1,UL2,... one or more comma separated uplinks
--type TYPE area import type. valid options are "bbs" and "na"
NetMail is sent to supplied address with the supplied command(s). Multi-part commands
such as "%COMPRESS ZIP" should be quoted.
import-areas PATH Import areas using FidoNet *.NA or AREAS.BBS file
import-areas arguments:
--conf CONF_TAG Conference tag in which to import areas
--network NETWORK Network name/key to associate FTN areas
--uplinks UL1,UL2,... One or more uplinks (comma separated)
--type TYPE Area import type
Valid types are "bbs" and "na".
`
};

View File

@ -16,6 +16,7 @@ const UserProps = require('../user_property.js');
const async = require('async');
const _ = require('lodash');
const moment = require('moment');
const fs = require('fs-extra');
exports.handleUserCommand = handleUserCommand;
@ -227,6 +228,41 @@ function removeUser(user) {
);
}
function renameUser(user) {
if(argv._.length < 3) {
return printUsageAndSetExitCode(getHelpFor('User'), ExitCodes.ERROR);
}
const newUserName = argv._[argv._.length - 1];
async.series(
[
(callback) => {
const { validateUserNameAvail } = require('../../core/system_view_validate.js');
return validateUserNameAvail(newUserName, callback);
},
(callback) => {
const userDb = require('../../core/database.js').dbs.user;
userDb.run(
`UPDATE user
SET user_name = ?
WHERE id = ?;`,
[ newUserName, user.userId, ],
err => {
return callback(err);
}
);
}
],
err => {
if(err) {
return console.error(err.reason ? err.reason : err.message);
}
return console.info(`User "${user.username}" renamed to "${newUserName}"`);
}
);
}
function modUserGroups(user) {
if(argv._.length < 3) {
return printUsageAndSetExitCode(getHelpFor('User'), ExitCodes.ERROR);
@ -293,7 +329,7 @@ function showUserInfo(user) {
return user.properties[p] || 'N/A';
};
console.info(`User information:
const stdInfo = `User information:
Username : ${user.username}${user.isRoot() ? ' (root/SysOp)' : ''}
Real name : ${propOrNA(UserProps.RealName)}
ID : ${user.userId}
@ -304,8 +340,124 @@ Last login : ${lastLogin()}
Login count : ${propOrNA(UserProps.LoginCount)}
Email : ${propOrNA(UserProps.EmailAddress)}
Location : ${propOrNA(UserProps.Location)}
Affiliations : ${propOrNA(UserProps.Affiliations)}
`);
Affiliations : ${propOrNA(UserProps.Affiliations)}`;
let secInfo = '';
if(argv.security) {
const otp = user.getProperty(UserProps.AuthFactor2OTP);
if(otp) {
const backupCodesOrNa = () => {
try
{
return JSON.parse(user.getProperty(UserProps.AuthFactor2OTPBackupCodes)).join(', ');
} catch(e) {
return 'N/A';
}
};
secInfo = `\n2FA OTP : ${otp}
OTP secret : ${user.getProperty(UserProps.AuthFactor2OTPSecret) || 'N/A'}
OTP Backup : ${backupCodesOrNa()}`;
}
}
console.info(`${stdInfo}${secInfo}`);
}
function twoFactorAuthOTP(user) {
if(argv._.length < 4) {
return printUsageAndSetExitCode(getHelpFor('User'), ExitCodes.ERROR);
}
const {
OTPTypes,
prepareOTP,
createBackupCodes,
} = require('../../core/user_2fa_otp.js');
let otpType = argv._[argv._.length - 1];
// shortcut for removal
if('disable' === otpType) {
const props = [
UserProps.AuthFactor2OTP,
UserProps.AuthFactor2OTPSecret,
UserProps.AuthFactor2OTPBackupCodes,
];
return user.removeProperties(props, err => {
if(err) {
console.error(err.message);
} else {
console.info(`2FA OTP disabled for ${user.username}`);
}
});
}
async.waterfall(
[
function validate(callback) {
// :TODO: Prompt for if not supplied
// allow aliases for OTP types
otpType = {
google : OTPTypes.GoogleAuthenticator,
hotp : OTPTypes.RFC4266_HOTP,
totp : OTPTypes.RFC6238_TOTP,
}[otpType] || otpType;
otpType = _.find(OTPTypes, t => {
return t.toLowerCase() === otpType.toLowerCase();
});
if(!otpType) {
return callback(Errors.Invalid('Invalid OTP type'));
}
return callback(null, otpType);
},
function prepare(otpType, callback) {
const otpOpts = {
username : user.username,
qrType : argv['qr-type'] || 'ascii',
};
prepareOTP(otpType, otpOpts, (err, otpInfo) => {
return callback(err, Object.assign(otpInfo, { otpType, backupCodes : createBackupCodes() }));
});
},
function storeOrDisplayQR(otpInfo, callback) {
if(!argv.out || !otpInfo.qr) {
return callback(null, otpInfo);
}
fs.writeFile(argv.out, otpInfo.qr, 'utf8', err => {
return callback(err, otpInfo);
});
},
function persist(otpInfo, callback) {
const props = {
[ UserProps.AuthFactor2OTP ] : otpInfo.otpType,
[ UserProps.AuthFactor2OTPSecret ] : otpInfo.secret,
[ UserProps.AuthFactor2OTPBackupCodes ] : JSON.stringify(otpInfo.backupCodes),
};
user.persistProperties(props, err => {
return callback(err, otpInfo);
});
}
],
(err, otpInfo) => {
if(err) {
console.error(err.message);
} else {
console.info(`OTP enabled for : ${user.username}`);
console.info(`Secret : ${otpInfo.secret}`);
console.info(`Backup codes : ${otpInfo.backupCodes.join(', ')}`);
if(otpInfo.qr) {
if(!argv.out) {
console.info('--- Begin QR ---');
console.info(otpInfo.qr);
console.info('--- End QR ---');
} else {
console.info(`QR code saved to ${argv.out}`);
}
}
}
}
);
}
function handleUserCommand() {
@ -317,9 +469,14 @@ function handleUserCommand() {
return errUsage();
}
const action = argv._[1];
const usernameIdx = [ 'pw', 'pass', 'passwd', 'password', 'group' ].includes(action) ? argv._.length - 2 : argv._.length - 1;
const userName = argv._[usernameIdx];
const action = argv._[1];
const usernameIdx = [
'pw', 'pass', 'passwd', 'password',
'group',
'mv', 'rename',
'2fa-otp', 'otp'
].includes(action) ? argv._.length - 2 : argv._.length - 1;
const userName = argv._[usernameIdx];
if(!userName) {
return errUsage();
@ -341,6 +498,9 @@ function handleUserCommand() {
del : removeUser,
delete : removeUser,
mv : renameUser,
rename : renameUser,
activate : setAccountStatus,
deactivate : setAccountStatus,
disable : setAccountStatus,
@ -349,6 +509,9 @@ function handleUserCommand() {
group : modUserGroups,
info : showUserInfo,
'2fa-otp' : twoFactorAuthOTP,
otp : twoFactorAuthOTP,
}[action] || errUsage)(user, action);
});
}

View File

@ -0,0 +1,304 @@
/* jslint node: true */
'use strict';
// ENiGMA½
const Log = require('../../logger.js').log;
const { ServerModule } = require('../../server_module.js');
const Config = require('../../config.js').get;
const { Errors } = require('../../enig_error.js');
const SysProps = require('../../system_property.js');
const StatLog = require('../../stat_log.js');
// deps
const net = require('net');
const _ = require('lodash');
const os = require('os');
// MRC
const protocolVersion = '1.2.9';
const lineDelimiter = new RegExp('\r\n|\r|\n');
const ModuleInfo = exports.moduleInfo = {
name : 'MRC',
desc : 'An MRC Chat Multiplexer',
author : 'RiPuk',
packageName : 'codes.l33t.enigma.mrc.server',
notes : 'https://bbswiki.bottomlessabyss.net/index.php?title=MRC_Chat_platform',
};
const connectedSockets = new Set();
exports.getModule = class MrcModule extends ServerModule {
constructor() {
super();
this.log = Log.child( { server : 'MRC' } );
const config = Config();
this.boardName = config.general.prettyBoardName || config.general.boardName;
this.mrcConnectOpts = {
host : config.chatServers.mrc.serverHostname || 'mrc.bottomlessabyss.net',
port : config.chatServers.mrc.serverPort || 5000,
retryDelay : config.chatServers.mrc.retryDelay || 10000
};
}
_connectionHandler() {
const enigmaVersion = 'ENiGMA½-BBS_' + require('../../../package.json').version;
const handshake = `${this.boardName}~${enigmaVersion}/${os.platform()}.${os.arch()}/${protocolVersion}`;
this.log.debug({ handshake : handshake }, 'Handshaking with MRC server');
this.sendRaw(handshake);
this.log.info(this.mrcConnectOpts, 'Connected to MRC server');
}
createServer(cb) {
if (!this.enabled) {
return cb(null);
}
this.connectToMrc();
this.createLocalListener();
return cb(null);
}
listen(cb) {
if (!this.enabled) {
return cb(null);
}
const config = Config();
const port = parseInt(config.chatServers.mrc.multiplexerPort);
if(isNaN(port)) {
this.log.warn( { port : config.chatServers.mrc.multiplexerPort, server : ModuleInfo.name }, 'Invalid port' );
return cb(Errors.Invalid(`Invalid port: ${config.chatServers.mrc.multiplexerPort}`));
}
Log.info( { server : ModuleInfo.name, port : config.chatServers.mrc.multiplexerPort }, 'MRC multiplexer starting up');
return this.server.listen(port, cb);
}
/**
* Handles connecting to to the MRC server
*/
connectToMrc() {
const self = this;
// create connection to MRC server
this.mrcClient = net.createConnection(this.mrcConnectOpts, self._connectionHandler.bind(self));
this.mrcClient.requestedDisconnect = false;
// do things when we get data from MRC central
let buffer = new Buffer.from('');
function handleData(chunk) {
if(_.isString(chunk)) {
buffer += chunk;
} else {
buffer = Buffer.concat([buffer, chunk]);
}
let lines = buffer.toString().split(lineDelimiter);
if (lines.pop()) {
// if buffer is not ended with \r\n, there's more chunks.
return;
} else {
// else, initialize the buffer.
buffer = new Buffer.from('');
}
lines.forEach( line => {
if (line.length) {
let message = self.parseMessage(line);
if (message) {
self.receiveFromMRC(message);
}
}
});
}
this.mrcClient.on('data', (data) => {
handleData(data);
});
this.mrcClient.on('end', () => {
this.log.info(this.mrcConnectOpts, 'Disconnected from MRC server');
});
this.mrcClient.on('close', () => {
if (this.mrcClient && this.mrcClient.requestedDisconnect)
return;
this.log.info(this.mrcConnectOpts, 'Disconnected from MRC server, reconnecting');
this.log.debug('Waiting ' + this.mrcConnectOpts.retryDelay + 'ms before retrying');
setTimeout(function() {
self.connectToMrc();
}, this.mrcConnectOpts.retryDelay);
});
this.mrcClient.on('error', err => {
this.log.info( { error : err.message }, 'MRC server error');
});
}
createLocalListener() {
// start a local server for clients to connect to
this.server = net.createServer( socket => {
socket.setEncoding('ascii');
socket.on('data', data => {
// split on \n to deal with getting messages in batches
data.toString().split(lineDelimiter).forEach( item => {
if (item == '') return;
// save username with socket
if(item.startsWith('--DUDE-ITS--')) {
connectedSockets.add(socket);
socket.username = item.split('|')[1];
Log.debug( { server : 'MRC', user: socket.username } , 'User connected');
} else {
this.receiveFromClient(socket.username, item);
}
});
});
socket.on('end', function() {
connectedSockets.delete(socket);
});
socket.on('error', err => {
if('ECONNRESET' !== err.code) { // normal
this.log.error( { error: err.message }, 'MRC error' );
}
});
});
}
get enabled() {
return _.get(Config(), 'chatServers.mrc.enabled', false) && this.isConfigured();
}
isConfigured() {
const config = Config();
return _.isNumber(_.get(config, 'chatServers.mrc.multiplexerPort'));
}
/**
* Sends received messages to local clients
*/
sendToClient(message) {
connectedSockets.forEach( (client) => {
if (message.to_user == '' || message.to_user == client.username || message.to_user == 'CLIENT' || message.from_user == client.username || message.to_user == 'NOTME' ) {
// this.log.debug({ server : 'MRC', username : client.username, message : message }, 'Forwarding message to connected user');
client.write(JSON.stringify(message) + '\n');
}
});
}
/**
* Processes messages received from the central MRC server
*/
receiveFromMRC(message) {
const config = Config();
if (message.from_user == 'SERVER' && message.body == 'HELLO') {
// reply with extra bbs info
this.sendToMrcServer('CLIENT', '', 'SERVER', 'ALL', '', `INFOSYS:${StatLog.getSystemStat(SysProps.SysOpUsername)}`);
this.sendToMrcServer('CLIENT', '', 'SERVER', 'ALL', '', `INFOWEB:${config.general.website}`);
this.sendToMrcServer('CLIENT', '', 'SERVER', 'ALL', '', `INFOTEL:${config.general.telnetHostname}`);
this.sendToMrcServer('CLIENT', '', 'SERVER', 'ALL', '', `INFOSSH:${config.general.sshHostname}`);
this.sendToMrcServer('CLIENT', '', 'SERVER', 'ALL', '', `INFODSC:${config.general.description}`);
} else if (message.from_user == 'SERVER' && message.body.toUpperCase() == 'PING') {
// reply to heartbeat
this.sendToMrcServer('CLIENT', '', 'SERVER', 'ALL', '', `IMALIVE:${this.boardName}`);
} else {
// if not a heartbeat, and we have clients then we need to send something to them
this.sendToClient(message);
}
}
/**
* Takes an MRC message and parses it into something usable
*/
parseMessage(line) {
const [from_user, from_site, from_room, to_user, to_site, to_room, body ] = line.split('~');
// const msg = line.split('~');
// if (msg.length < 7) {
// return;
// }
return { from_user, from_site, from_room, to_user, to_site, to_room, body };
}
/**
* Receives a message from a local client and sanity checks before sending on to the central MRC server
*/
receiveFromClient(username, message) {
try {
message = JSON.parse(message);
} catch (e) {
Log.debug({ server : 'MRC', user : username, message : message }, 'Dodgy message received from client');
}
this.sendToMrcServer(message.from_user, message.from_room, message.to_user, message.to_site, message.to_room, message.body);
}
/**
* Converts a message back into the MRC format and sends it to the central MRC server
*/
sendToMrcServer(fromUser, fromRoom, toUser, toSite, toRoom, messageBody) {
const line = [
fromUser,
this.boardName,
sanitiseRoomName(fromRoom),
sanitiseName(toUser || ''),
sanitiseName(toSite || ''),
sanitiseRoomName(toRoom || ''),
sanitiseMessage(messageBody)
].join('~') + '~';
// Log.debug({ server : 'MRC', data : line }, 'Sending data');
this.sendRaw(line);
}
sendRaw(message) {
// optionally log messages here
this.mrcClient.write(message + '\n');
}
};
/**
* User / site name must be ASCII 33-125, no MCI, 30 chars max, underscores
*/
function sanitiseName(str) {
return str.replace(
/\s/g, '_'
).replace(
/[^\x21-\x7D]|(\|\w\w)/g, '' // Non-printable & MCI
).substr(
0, 30
);
}
function sanitiseRoomName(message) {
return message.replace(/[^\x21-\x7D]|(\|\w\w)/g, '').substr(0, 30);
}
function sanitiseMessage(message) {
return message.replace(/[^\x20-\x7D]/g, '');
}

View File

@ -113,7 +113,7 @@ exports.getModule = class GopherModule extends ServerModule {
return cb(Errors.Invalid(`Invalid port: ${config.contentServers.gopher.port}`));
}
return this.server.listen(port, cb);
return this.server.listen(port, config.contentServers.gopher.address, cb);
}
get enabled() {

View File

@ -141,7 +141,7 @@ class NNTPServer extends NNTPServerBase {
return new Promise( resolve => {
const user = new User();
user.authenticate(username, password, err => {
user.authenticateFactor1({ type : User.AuthFactor1Types.Password, username, password }, err => {
if(err) {
// :TODO: Log IP address
this.log.debug( { username, reason : err.message }, 'Authentication failure');
@ -275,7 +275,7 @@ class NNTPServer extends NNTPServerBase {
//
const remoteFrom = _.get(message.meta, [ 'System', Message.SystemMetaNames.RemoteFromUser ]);
message.nntpHeaders['X-FTN-From'] = remoteFrom ? `${fromName} <${remoteFrom}>` : fromName;
const remoteTo = _.get(message.meta [ 'System', Message.SystemMetaNames.RemoteToUser ]);
const remoteTo = _.get(message.meta, [ 'System', Message.SystemMetaNames.RemoteToUser ]);
message.nntpHeaders['X-FTN-To'] = remoteTo ? `${toName} <${remoteTo}>` : toName;
if(!message.replyToMsgId) {

View File

@ -138,7 +138,7 @@ exports.getModule = class WebServerModule extends ServerModule {
return nextService(Errors.Invalid(`Invalid port: ${config.contentServers.web[service].port}`));
}
this[name].listen(port, err => {
this[name].listen(port, config.contentServers.web[service].address, err => {
return nextService(err);
});
} else {

View File

@ -14,6 +14,8 @@ const {
Errors,
ErrorReasons
} = require('../../enig_error.js');
const User = require('../../user.js');
const UserProps = require('../../user_property.js');
// deps
const ssh2 = require('ssh2');
@ -42,8 +44,6 @@ function SSHClient(clientConn) {
clientConn.on('authentication', function authAttempt(ctx) {
const username = ctx.username || '';
const password = ctx.password || '';
const config = Config();
self.isNewUser = (config.users.newUserNames || []).indexOf(username) > -1;
@ -106,37 +106,36 @@ function SSHClient(clientConn) {
}
};
//
// If the system is open and |isNewUser| is true, the login
// sequence is hijacked in order to start the application process.
//
if(false === config.general.closedSystem && self.isNewUser) {
return ctx.accept();
}
const authWithPasswordOrPubKey = (authType) => {
if(User.AuthFactor1Types.SSHPubKey !== authType || !self.user.isAuthenticated() || !ctx.signature) {
// step 1: login/auth using PubKey
userLogin(self, ctx.username, ctx.password, { authType, ctx }, (err) => {
if(err) {
if(isSpecialHandleError(err)) {
return handleSpecialError(err, username);
}
if(username.length > 0 && password.length > 0) {
userLogin(self, ctx.username, ctx.password, function authResult(err) {
if(err) {
if(isSpecialHandleError(err)) {
return handleSpecialError(err, username);
if(Errors.BadLogin().code === err.code) {
return slowTerminateConnection();
}
return safeContextReject(SSHClient.ValidAuthMethods);
}
if(Errors.BadLogin().code === err.code) {
return slowTerminateConnection();
}
return safeContextReject(SSHClient.ValidAuthMethods);
ctx.accept();
});
} else {
// step 2: verify signature
const pubKeyActual = ssh2.utils.parseKey(self.user.getProperty(UserProps.AuthPubKey));
if(!pubKeyActual || !pubKeyActual.verify(ctx.blob, ctx.signature)) {
return slowTerminateConnection();
}
ctx.accept();
});
} else {
if(-1 === SSHClient.ValidAuthMethods.indexOf(ctx.method)) {
return safeContextReject(SSHClient.ValidAuthMethods);
return ctx.accept();
}
};
const authKeyboardInteractive = () => {
if(0 === username.length) {
// :TODO: can we display something here?
return safeContextReject();
}
@ -176,6 +175,30 @@ function SSHClient(clientConn) {
}
});
});
};
//
// If the system is open and |isNewUser| is true, the login
// sequence is hijacked in order to start the application process.
//
if(false === config.general.closedSystem && self.isNewUser) {
return ctx.accept();
}
switch(ctx.method) {
case 'password' :
return authWithPasswordOrPubKey(User.AuthFactor1Types.Password);
//return authWithPassword();
case 'publickey' :
return authWithPasswordOrPubKey(User.AuthFactor1Types.SSHPubKey);
//return authWithPubKey();
case 'keyboard-interactive' :
return authKeyboardInteractive();
default :
return safeContextReject(SSHClient.ValidAuthMethods);
}
});
@ -293,7 +316,11 @@ function SSHClient(clientConn) {
util.inherits(SSHClient, baseClient.Client);
SSHClient.ValidAuthMethods = [ 'password', 'keyboard-interactive' ];
SSHClient.ValidAuthMethods = [
'password',
'keyboard-interactive',
'publickey',
];
exports.getModule = class SSHServerModule extends LoginServerModule {
constructor() {
@ -353,7 +380,7 @@ exports.getModule = class SSHServerModule extends LoginServerModule {
return cb(Errors.Invalid(`Invalid port: ${config.loginServers.ssh.port}`));
}
this.server.listen(port, err => {
this.server.listen(port, config.loginServers.ssh.address, err => {
if(!err) {
Log.info( { server : ModuleInfo.name, port : port }, 'Listening for connections' );
}

View File

@ -875,7 +875,7 @@ exports.getModule = class TelnetServerModule extends LoginServerModule {
return cb(Errors.Invalid(`Invalid port: ${config.loginServers.telnet.port}`));
}
this.server.listen(port, err => {
this.server.listen(port, config.loginServers.telnet.address, err => {
if(!err) {
Log.info( { server : ModuleInfo.name, port : port }, 'Listening for connections' );
}

View File

@ -88,7 +88,7 @@ function WebSocketClient(ws, req, serverType) {
});
//
// Montior connection status with ping/pong
// Monitor connection status with ping/pong
//
ws.on('pong', () => {
Log.trace(`Pong from ${this.socketBridge.remoteAddress}`);
@ -200,7 +200,8 @@ exports.getModule = class WebSocketLoginServer extends LoginServerModule {
}
const serverName = `${ModuleInfo.name} (${serverType})`;
const confPort = _.get(Config(), [ 'loginServers', 'webSocket', 'secure' === serverType ? 'wss' : 'ws', 'port' ] );
const conf = _.get(Config(), [ 'loginServers', 'webSocket', 'secure' === serverType ? 'wss' : 'ws' ] );
const confPort = conf.port;
const port = parseInt(confPort);
if(isNaN(port)) {
@ -208,7 +209,7 @@ exports.getModule = class WebSocketLoginServer extends LoginServerModule {
return nextServerType(Errors.Invalid(`Invalid port: ${confPort}`));
}
server.httpServer.listen(port, err => {
server.httpServer.listen(port, conf.address, err => {
if(err) {
return nextServerType(err);
}

View File

@ -68,6 +68,15 @@ exports.getModule = class ShowArtModule extends MenuModule {
}
showByExtraArgs(cb) {
const artData = _.get(this.config, 'extraArgs.artData');
if(Buffer.isBuffer(artData)) {
const options = {
pause : this.shouldPause(),
desc : 'extraArgs',
};
return this.displaySingleArtWithOptions(artData, options, cb);
}
this.getArtKeyValue(this.config.key, (err, artSpec) => {
if(err) {
return cb(err);

View File

@ -29,9 +29,11 @@ exports.isAnsiLine = isAnsiLine;
exports.isFormattedLine = isFormattedLine;
exports.splitTextAtTerms = splitTextAtTerms;
// :TODO: create Unicode verison of this
const VOWELS = [ 'a', 'e', 'i', 'o', 'u' ];
VOWELS.concat(VOWELS.map(l => l.toUpperCase()));
// :TODO: create Unicode version of this
const VOWELS = [
'a', 'e', 'i', 'o', 'u',
'A', 'E', 'I', 'O', 'U',
];
const SIMPLE_ELITE_MAP = {
'a' : '4',

View File

@ -8,12 +8,16 @@ const { userLogin } = require('./user_login.js');
const messageArea = require('./message_area.js');
const { ErrorReasons } = require('./enig_error.js');
const UserProps = require('./user_property.js');
const {
loginFactor2_OTP
} = require('./user_2fa_otp.js');
// deps
const _ = require('lodash');
const iconv = require('iconv-lite');
exports.login = login;
exports.login2FA_OTP = login2FA_OTP;
exports.logoff = logoff;
exports.prevMenu = prevMenu;
exports.nextMenu = nextMenu;
@ -23,32 +27,47 @@ exports.prevArea = prevArea;
exports.nextArea = nextArea;
exports.sendForgotPasswordEmail = sendForgotPasswordEmail;
const handleAuthFailures = (callingMenu, err, cb) => {
// already logged in with this user?
if(ErrorReasons.AlreadyLoggedIn === err.reasonCode &&
_.has(callingMenu, 'menuConfig.config.tooNodeMenu'))
{
return callingMenu.gotoMenu(callingMenu.menuConfig.config.tooNodeMenu, cb);
}
// banned username results in disconnect
if(ErrorReasons.NotAllowed === err.reasonCode) {
return logoff(callingMenu, {}, {}, 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);
};
function login(callingMenu, formData, extraArgs, cb) {
userLogin(callingMenu.client, formData.value.username, formData.value.password, err => {
if(err) {
// already logged in with this user?
if(ErrorReasons.AlreadyLoggedIn === err.reasonCode &&
_.has(callingMenu, 'menuConfig.config.tooNodeMenu'))
{
return callingMenu.gotoMenu(callingMenu.menuConfig.config.tooNodeMenu, cb);
}
return handleAuthFailures(callingMenu, err, cb);
}
// banned username results in disconnect
if(ErrorReasons.NotAllowed === err.reasonCode) {
return logoff(callingMenu, {}, {}, cb);
}
// success!
return callingMenu.nextMenu(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);
function login2FA_OTP(callingMenu, formData, extraArgs, cb) {
loginFactor2_OTP(callingMenu.client, formData.value.token, err => {
if(err) {
return handleAuthFailures(callingMenu, err, cb);
}
// success!

View File

@ -27,6 +27,7 @@ exports.getAvailableThemes = getAvailableThemes;
exports.getRandomTheme = getRandomTheme;
exports.setClientTheme = setClientTheme;
exports.initAvailableThemes = initAvailableThemes;
exports.displayPreparedArt = displayPreparedArt;
exports.displayThemeArt = displayThemeArt;
exports.displayThemedPause = displayThemedPause;
exports.displayThemedPrompt = displayThemedPrompt;
@ -439,7 +440,7 @@ function getThemeArt(options, cb) {
// :TODO: replace asAnsi stuff with something like retrieveAs = 'ansi' | 'pipe' | ...
// :TODO: Some of these options should only be set if not provided!
options.asAnsi = true; // always convert to ANSI
options.readSauce = true; // read SAUCE, if avail
options.readSauce = _.get(options, 'readSauce', true); // read SAUCE, if avail
options.random = _.get(options, 'random', true); // FILENAME<n>.EXT support
//
@ -510,6 +511,17 @@ function getThemeArt(options, cb) {
);
}
function displayPreparedArt(options, artInfo, cb) {
const displayOpts = {
sauce : artInfo.sauce,
font : options.font,
trailingLF : options.trailingLF,
};
art.display(options.client, artInfo.data, displayOpts, (err, mciMap, extraInfo) => {
return cb(err, { mciMap : mciMap, artInfo : artInfo, extraInfo : extraInfo } );
});
}
function displayThemeArt(options, cb) {
assert(_.isObject(options));
assert(_.isObject(options.client));
@ -537,14 +549,7 @@ function displayThemeArt(options, cb) {
}
},
function disp(artInfo, callback) {
const displayOpts = {
sauce : artInfo.sauce,
font : options.font,
trailingLF : options.trailingLF,
};
art.display(options.client, artInfo.data, displayOpts, (err, mciMap, extraInfo) => {
return callback(err, { mciMap : mciMap, artInfo : artInfo, extraInfo : extraInfo } );
});
return displayPreparedArt(options, artInfo, callback);
}
],
(err, artData) => {

View File

@ -35,11 +35,16 @@ util.inherits(ToggleMenuView, MenuView);
ToggleMenuView.prototype.redraw = function() {
ToggleMenuView.super_.prototype.redraw.call(this);
if(0 === this.items.length) {
return;
}
//this.cachePositions();
this.client.term.write(this.hasFocus ? this.getFocusSGR() : this.getSGR());
assert(this.items.length === 2);
assert(this.items.length === 2, 'ToggleMenuView must contain exactly (2) items');
for(var i = 0; i < 2; i++) {
var item = this.items[i];
var text = strUtil.stylizeString(
@ -102,7 +107,7 @@ ToggleMenuView.prototype.onKeyPress = function(ch, key) {
if(key) {
if(this.isKeyMapped('right', key.name) || this.isKeyMapped('down', key.name)) {
this.focusNext();
} else if(this.isKeyMapped('left', key.name) || this.isKeyMapped('up', key.nam4e)) {
} else if(this.isKeyMapped('left', key.name) || this.isKeyMapped('up', key.name)) {
this.focusPrevious();
}
}

View File

@ -387,13 +387,11 @@ exports.getModule = class UploadModule extends MenuModule {
self.client.log.error(
'Failed moving physical upload file', { error : err.message, fileName : newEntry.fileName, source : src, dest : dst }
);
if(!err && dst !== finalPath) {
// name changed; ajust before persist
newEntry.fileName = paths.basename(finalPath);
}
return nextEntry(null); // still try next file
} else if(dst !== finalPath)
{
// name changed; adjust before persist
newEntry.fileName = paths.basename(finalPath);
}
self.client.log.debug('Moved upload to area', { path : finalPath } );

View File

@ -21,15 +21,17 @@ const async = require('async');
const _ = require('lodash');
const moment = require('moment');
const sanatizeFilename = require('sanitize-filename');
const ssh2 = require('ssh2');
exports.isRootUserId = function(id) { return 1 === id; };
module.exports = class User {
constructor() {
this.userId = 0;
this.username = '';
this.properties = {}; // name:value
this.groups = []; // group membership(s)
this.userId = 0;
this.username = '';
this.properties = {}; // name:value
this.groups = []; // group membership(s)
this.authFactor = User.AuthFactors.None;
}
// static property accessors
@ -37,6 +39,14 @@ module.exports = class User {
return 1;
}
static get AuthFactors() {
return {
None : 0, // Not yet authenticated in any way
Factor1 : 1, // username + password/pubkey/etc. checked out
Factor2 : 2, // validated with 2FA of some sort such as OTP
};
}
static get PBKDF2() {
return {
iterations : 1000,
@ -47,7 +57,10 @@ module.exports = class User {
static get StandardPropertyGroups() {
return {
password : [ UserProps.PassPbkdf2Salt, UserProps.PassPbkdf2Dk ],
auth : [
UserProps.PassPbkdf2Salt, UserProps.PassPbkdf2Dk,
UserProps.AuthPubKey,
],
};
}
@ -60,18 +73,6 @@ module.exports = class User {
};
}
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;
}
@ -186,10 +187,53 @@ module.exports = class User {
});
}
authenticate(username, password, cb) {
static get AuthFactor1Types() {
return {
SSHPubKey : 'sshPubKey',
Password : 'password',
TLSClient : 'tlsClientAuth',
};
}
authenticateFactor1(authInfo, cb) {
const username = authInfo.username;
const self = this;
const tempAuthInfo = {};
const validatePassword = (props, callback) => {
User.generatePasswordDerivedKey(authInfo.password, props[UserProps.PassPbkdf2Salt], (err, dk) => {
if(err) {
return callback(err);
}
//
// Use constant time comparison here for security feel-goods
//
const passDkBuf = Buffer.from(dk, 'hex');
const propsDkBuf = Buffer.from(props[UserProps.PassPbkdf2Dk], 'hex');
return callback(crypto.timingSafeEqual(passDkBuf, propsDkBuf) ?
null :
Errors.AccessDenied('Invalid password')
);
});
};
const validatePubKey = (props, callback) => {
const pubKeyActual = ssh2.utils.parseKey(props[UserProps.AuthPubKey]);
if(!pubKeyActual) {
return callback(Errors.AccessDenied('Invalid public key'));
}
if(authInfo.pubKey.key.algo != pubKeyActual.type ||
!crypto.timingSafeEqual(authInfo.pubKey.key.data, pubKeyActual.getPublicSSH()))
{
return callback(Errors.AccessDenied('Invalid public key'));
}
return callback(null);
};
async.waterfall(
[
function fetchUserId(callback) {
@ -203,27 +247,15 @@ module.exports = class User {
},
function getRequiredAuthProperties(callback) {
// fetch properties required for authentication
User.loadProperties(tempAuthInfo.userId, { names : User.StandardPropertyGroups.password }, (err, props) => {
User.loadProperties(tempAuthInfo.userId, { names : User.StandardPropertyGroups.auth }, (err, props) => {
return callback(err, props);
});
},
function getDkWithSalt(props, callback) {
// get DK from stored salt and password provided
User.generatePasswordDerivedKey(password, props[UserProps.PassPbkdf2Salt], (err, dk) => {
return callback(err, dk, props[UserProps.PassPbkdf2Dk]);
});
},
function validateAuth(passDk, propsDk, callback) {
//
// Use constant time comparison here for security feel-goods
//
const passDkBuf = Buffer.from(passDk, 'hex');
const propsDkBuf = Buffer.from(propsDk, 'hex');
return callback(User.isSamePasswordSlowCompare(passDkBuf, propsDkBuf) ?
null :
Errors.AccessDenied('Invalid password')
);
function validatePassOrPubKey(props, callback) {
if(User.AuthFactor1Types.SSHPubKey === authInfo.type) {
return validatePubKey(props, callback);
}
return validatePassword(props, callback);
},
function initProps(callback) {
User.loadProperties(tempAuthInfo.userId, (err, allProps) => {
@ -301,7 +333,12 @@ module.exports = class User {
self.username = tempAuthInfo.username;
self.properties = tempAuthInfo.properties;
self.groups = tempAuthInfo.groups;
self.authenticated = true;
self.authFactor = User.AuthFactors.Factor1;
//
// If 2FA/OTP is required, this user is not quite authenticated yet.
//
self.authenticated = !(self.getProperty(UserProps.AuthFactor2OTP) ? true : false);
self.removeProperty(UserProps.FailedLoginAttempts);
@ -582,7 +619,10 @@ module.exports = class User {
user.username = userName;
user.properties = properties;
user.groups = groups;
user.authenticated = false; // this is NOT an authenticated user!
// explicitly NOT an authenticated user!
user.authenticated = false;
user.authFactor = User.AuthFactors.None;
return cb(err, user);
}

187
core/user_2fa_otp.js Normal file
View File

@ -0,0 +1,187 @@
/* jslint node: true */
'use strict';
// ENiGMA½
const UserProps = require('./user_property.js');
const {
Errors,
ErrorReasons,
} = require('./enig_error.js');
const User = require('./user.js');
const {
recordLogin,
transformLoginError,
} = require('./user_login.js');
const Config = require('./config.js').get;
// deps
const _ = require('lodash');
const crypto = require('crypto');
const qrGen = require('qrcode-generator');
exports.prepareOTP = prepareOTP;
exports.createBackupCodes = createBackupCodes;
exports.createQRCode = createQRCode;
exports.otpFromType = otpFromType;
exports.loginFactor2_OTP = loginFactor2_OTP;
const OTPTypes = exports.OTPTypes = {
RFC6238_TOTP : 'rfc6238_TOTP', // Time-Based, SHA-512
RFC4266_HOTP : 'rfc4266_HOTP', // HMAC-Based, SHA-512
GoogleAuthenticator : 'googleAuth', // Google Authenticator is basically TOTP + quirks
};
function otpFromType(otpType) {
try {
return {
[ OTPTypes.RFC6238_TOTP ] : () => {
const totp = require('otplib/totp');
totp.options = { crypto, algorithm : 'sha256' };
return totp;
},
[ OTPTypes.RFC4266_HOTP ] : () => {
const hotp = require('otplib/hotp');
hotp.options = { crypto, algorithm : 'sha256' };
return hotp;
},
[ OTPTypes.GoogleAuthenticator ] : () => {
const googleAuth = require('otplib/authenticator');
googleAuth.options = { crypto };
return googleAuth;
},
}[otpType]();
} catch(e) {
// nothing
}
}
function generateOTPBackupCode() {
const consonants = 'bdfghjklmnprstvz'.split('');
const vowels = 'aiou'.split('');
const bits = [];
const rng = crypto.randomBytes(4);
for(let i = 0; i < rng.length / 2; ++i) {
const n = rng.readUInt16BE(i * 2);
const c1 = n & 0x0f;
const v1 = (n >> 4) & 0x03;
const c2 = (n >> 6) & 0x0f;
const v2 = (n >> 10) & 0x03;
const c3 = (n >> 12) & 0x0f;
bits.push([
consonants[c1],
vowels[v1],
consonants[c2],
vowels[v2],
consonants[c3],
].join(''));
}
return bits.join('-');
}
function createBackupCodes() {
const codes = [...Array(6)].map(() => generateOTPBackupCode());
return codes;
}
function validateAndConsumeBackupCode(user, token, cb) {
try
{
let validCodes = JSON.parse(user.getProperty(UserProps.AuthFactor2OTPBackupCodes));
const matchingCode = validCodes.find(c => c === token);
if(!matchingCode) {
return cb(Errors.BadLogin('Invalid OTP value supplied', ErrorReasons.Invalid2FA));
}
// We're consuming a match - remove it from available backup codes
validCodes = validCodes.filter(c => c !== matchingCode);
validCodes = JSON.stringify(validCodes);
user.persistProperty(UserProps.AuthFactor2OTPBackupCodes, validCodes, err => {
return cb(err);
});
} catch(e) {
return cb(e);
}
}
function createQRCode(otp, options, secret) {
try {
const uri = otp.keyuri(options.username || 'user', Config().general.boardName, secret);
const qrCode = qrGen(0, 'L');
qrCode.addData(uri);
qrCode.make();
options.qrType = options.qrType || 'ascii';
return {
ascii : qrCode.createASCII,
data : qrCode.createDataURL,
img : qrCode.createImgTag,
svg : qrCode.createSvgTag,
}[options.qrType](options.cellSize);
} catch(e) {
return '';
}
}
function prepareOTP(otpType, options, cb) {
if(!_.isFunction(cb)) {
cb = options;
options = {};
}
const otp = otpFromType(otpType);
if(!otp) {
return cb(Errors.Invalid(`Unknown OTP type: ${otpType}`));
}
const secret = OTPTypes.GoogleAuthenticator === otpType ?
otp.generateSecret() :
crypto.randomBytes(64).toString('base64').substr(0, 32);
const qr = createQRCode(otp, options, secret);
return cb(null, { secret, qr } );
}
function loginFactor2_OTP(client, token, cb) {
if(client.user.authFactor < User.AuthFactors.Factor1) {
return cb(Errors.AccessDenied('OTP requires prior authentication factor 1'));
}
const otpType = client.user.getProperty(UserProps.AuthFactor2OTP);
const otp = otpFromType(otpType);
if(!otp) {
return cb(Errors.Invalid(`Unknown OTP type: ${otpType}`));
}
const secret = client.user.getProperty(UserProps.AuthFactor2OTPSecret);
if(!secret) {
return cb(Errors.Invalid('Missing OTP secret'));
}
const valid = otp.verify( { token, secret } );
const allowLogin = () => {
client.user.authFactor = User.AuthFactors.Factor2;
client.user.authenticated = true;
return recordLogin(client, cb);
};
if(valid) {
return allowLogin();
}
// maybe they punched in a backup code?
validateAndConsumeBackupCode(client.user, token, err => {
if(err) {
return cb(transformLoginError(err, client, client.user.username));
}
return allowLogin();
});
}

338
core/user_2fa_otp_config.js Normal file
View File

@ -0,0 +1,338 @@
/* jslint node: true */
'use strict';
// ENiGMA½
const { MenuModule } = require('./menu_module.js');
const UserProps = require('./user_property.js');
const {
OTPTypes,
otpFromType,
createQRCode,
createBackupCodes,
} = require('./user_2fa_otp.js');
const { Errors } = require('./enig_error.js');
const { getServer } = require('./listening_server.js');
const WebServerPackageName = require('./servers/content/web.js').moduleInfo.packageName;
const WebRegister = require('./user_2fa_otp_web_register.js');
// deps
const async = require('async');
const _ = require('lodash');
const iconv = require('iconv-lite');
exports.moduleInfo = {
name : 'User 2FA/OTP Configuration',
desc : 'Module for user 2FA/OTP configuration',
author : 'NuSkooler',
};
const FormIds = {
menu : 0,
};
const MciViewIds = {
enableToggle : 1,
otpType : 2,
submit : 3,
infoText : 4,
customRangeStart : 10, // 10+ = customs
};
const DefaultMsg = {
infoText: {
disabled : 'Enabling 2-factor authentication can greatly increase account security.',
enabled : 'A valid email address set in user config is required to enable 2-Factor Authentication.',
rfc6238_TOTP : 'Time-Based One-Time-Password (TOTP, RFC-6238).',
rfc4266_HOTP : 'HMAC-Based One-Time-Password (HOTP, RFC-4266).',
googleAuth : 'Google Authenticator.',
},
statusText : {
otpNotEnabled : '2FA/OTP is not currently enabled for this account.',
noBackupCodes : 'No backup codes remaining or set.',
saveDisabled : '2FA/OTP is now disabled for this account.',
saveEmailSent : 'An 2FA/OTP registration email has been sent with further instructions.',
saveError : 'Failed to send email. Please contact the system operator.',
qrNotAvail : 'QR code not available for this OTP type.',
emailRequired : 'Your account must have a valid email address set to use this feature.',
}
};
exports.getModule = class User2FA_OTPConfigModule extends MenuModule {
constructor(options) {
super(options);
this.config = Object.assign({}, _.get(options, 'menuConfig.config'), { extraArgs : options.extraArgs });
this.menuMethods = {
showQRCode : (formData, extraArgs, cb) => {
return this.showQRCode(cb);
},
showSecret : (formData, extraArgs, cb) => {
return this.showSecret(cb);
},
showBackupCodes : (formData, extraArgs, cb) => {
return this.showBackupCodes(cb);
},
generateNewBackupCodes : (formData, extraArgs, cb) => {
return this.generateNewBackupCodes(cb);
},
saveChanges : (formData, extraArgs, cb) => {
return this.saveChanges(formData, cb);
}
};
}
initSequence() {
const webServer = getServer(WebServerPackageName);
if(!webServer || !webServer.instance.isEnabled()) {
this.client.log.warn('User 2FA/OTP configuration requires the web server to be enabled!');
return this.prevMenu( () => { /* dummy */ } );
}
return super.initSequence();
}
mciReady(mciData, cb) {
super.mciReady(mciData, err => {
if(err) {
return cb(err);
}
async.series(
[
(callback) => {
return this.prepViewController('menu', FormIds.menu, mciData.menu, callback);
},
(callback) => {
const requiredCodes = [
MciViewIds.enableToggle,
MciViewIds.otpType,
MciViewIds.submit,
];
return this.validateMCIByViewIds('menu', requiredCodes, callback);
},
(callback) => {
const enableToggleView = this.getView('menu', MciViewIds.enableToggle);
let initialIndex = this.isOTPEnabledForUser() ? 1 : 0;
enableToggleView.setFocusItemIndex(initialIndex);
this.enableToggleUpdate(initialIndex);
enableToggleView.on('index update', idx => {
return this.enableToggleUpdate(idx);
});
const otpTypeView = this.getView('menu', MciViewIds.otpType);
initialIndex = this.otpTypeIndexFromUserOTPType();
otpTypeView.setFocusItemIndex(initialIndex);
otpTypeView.on('index update', idx => {
return this.otpTypeUpdate(idx);
});
this.viewControllers.menu.on('return', view => {
if(view === enableToggleView) {
return this.enableToggleUpdate(enableToggleView.focusedItemIndex);
} else if (view === otpTypeView) {
return this.otpTypeUpdate(otpTypeView.focusedItemIndex);
}
});
return callback(null);
}
],
err => {
return cb(err);
}
);
});
}
displayDetails(details, cb) {
const modOpts = {
extraArgs : {
artData : iconv.encode(`${details}\r\n`, 'cp437'),
}
};
this.gotoMenu(
this.menuConfig.config.userTwoFactorAuthOTPConfigShowDetails || 'userTwoFactorAuthOTPConfigShowDetails',
modOpts,
cb
);
}
showQRCode(cb) {
const otp = otpFromType(this.client.user.getProperty(UserProps.AuthFactor2OTP));
let qrCode;
if(!otp) {
qrCode = this.getStatusText('otpNotEnabled');
} else {
const qrOptions = {
username : this.client.user.username,
qrType : 'ascii',
};
qrCode = createQRCode(
otp,
qrOptions,
this.client.user.getProperty(UserProps.AuthFactor2OTPSecret)
);
if(qrCode) {
qrCode = qrCode.replace(/\n/g, '\r\n');
} else {
qrCode = this.getStatusText('qrNotAvail');
}
}
return this.displayDetails(qrCode, cb);
}
showSecret(cb) {
const info =
this.client.user.getProperty(UserProps.AuthFactor2OTPSecret) ||
this.getStatusText('otpNotEnabled');
return this.displayDetails(info, cb);
}
showBackupCodes(cb) {
let info;
const noBackupCodes = this.getStatusText('noBackupCodes');
if(!this.isOTPEnabledForUser()) {
info = this.getStatusText('otpNotEnabled');
} else {
try {
info = JSON.parse(this.client.user.getProperty(UserProps.AuthFactor2OTPBackupCodes) || '[]').join(', ');
info = info || noBackupCodes;
} catch(e) {
info = noBackupCodes;
}
}
return this.displayDetails(info, cb);
}
generateNewBackupCodes(cb) {
if(!this.isOTPEnabledForUser()) {
const info = this.getStatusText('otpNotEnabled');
return this.displayDetails(info, cb);
}
const backupCodes = createBackupCodes();
this.client.user.persistProperty(
UserProps.AuthFactor2OTPBackupCodes,
JSON.stringify(backupCodes),
err => {
if(err) {
return cb(err);
}
const info = backupCodes.join(', ');
return this.displayDetails(info, cb);
}
);
}
saveChanges(formData, cb) {
const enabled = 1 === _.get(formData, 'value.enableToggle', 0);
return enabled ? this.saveChangesEnable(formData, cb) : this.saveChangesDisable(cb);
}
saveChangesEnable(formData, cb) {
// User must have an email address set to save
const emailRegExp = /[a-z0-9!#$%&'*+/=?^_`{|}~.-]+@[a-z0-9-]+(.[a-z0-9-]+)*/;
const emailAddr = this.client.user.getProperty(UserProps.EmailAddress);
if(!emailAddr || !emailRegExp.test(emailAddr)) {
const info = this.getStatusText('emailRequired');
return this.displayDetails(info, cb);
}
const otpTypeProp = this.otpTypeFromOTPTypeIndex(_.get(formData, 'value.otpType'));
const saveFailedError = (err) => {
const info = this.getStatusText('saveError');
this.displayDetails(info, () => {
return cb(err);
});
};
// sanity check
if(!otpFromType(otpTypeProp)) {
return saveFailedError(Errors.Invalid('Cannot convert selected index to valid OTP type'));
}
this.removeUserOTPProperties(err => {
if(err) {
return saveFailedError(err);
}
WebRegister.sendRegisterEmail(this.client.user, otpTypeProp, err => {
if(err) {
return saveFailedError(err);
}
const info = this.getStatusText('saveEmailSent');
return this.displayDetails(info, cb);
});
});
}
removeUserOTPProperties(cb) {
const props = [
UserProps.AuthFactor2OTP,
UserProps.AuthFactor2OTPSecret,
UserProps.AuthFactor2OTPBackupCodes,
];
return this.client.user.removeProperties(props, cb);
}
saveChangesDisable(cb) {
this.removeUserOTPProperties(err => {
if(err) {
return cb(err);
}
const info = this.getStatusText('saveDisabled');
return this.displayDetails(info, cb);
});
}
isOTPEnabledForUser() {
return this.client.user.getProperty(UserProps.AuthFactor2OTP) ? true : false;
}
getInfoText(key) {
return _.get(this.config, [ 'infoText', key ], DefaultMsg.infoText[key]);
}
getStatusText(key) {
return _.get(this.config, [ 'statusText', key ], DefaultMsg.statusText[key]);
}
enableToggleUpdate(idx) {
const key = {
0 : 'disabled',
1 : 'enabled',
}[idx];
this.updateCustomViewTextsWithFilter('menu', MciViewIds.customRangeStart, { infoText : this.getInfoText(key) } );
}
otpTypeIndexFromUserOTPType(defaultIndex = 0) {
const type = this.client.user.getProperty(UserProps.AuthFactor2OTP);
return {
[ OTPTypes.RFC6238_TOTP ] : 0,
[ OTPTypes.RFC4266_HOTP ] : 1,
[ OTPTypes.GoogleAuthenticator ] : 2,
}[type] || defaultIndex;
}
otpTypeFromOTPTypeIndex(idx) {
return {
0 : OTPTypes.RFC6238_TOTP,
1 : OTPTypes.RFC4266_HOTP,
2 : OTPTypes.GoogleAuthenticator,
}[idx];
}
otpTypeUpdate(idx) {
const key = this.otpTypeFromOTPTypeIndex(idx);
this.updateCustomViewTextsWithFilter('menu', MciViewIds.customRangeStart, { infoText : this.getInfoText(key) } );
}
};

View File

@ -0,0 +1,292 @@
/* jslint node: true */
'use strict';
// ENiGMA½
const Config = require('./config.js').get;
const getServer = require('./listening_server.js').getServer;
const webServerPackageName = require('./servers/content/web.js').moduleInfo.packageName;
const {
createToken,
deleteToken,
getTokenInfo,
WellKnownTokenTypes,
} = require('./user_temp_token.js');
const {
prepareOTP,
createBackupCodes,
otpFromType,
} = require('./user_2fa_otp.js');
const { sendMail } = require('./email.js');
const UserProps = require('./user_property.js');
const Log = require('./logger.js').log;
const {
getConnectionByUserId
} = require('./client_connections.js');
// deps
const async = require('async');
const fs = require('fs-extra');
const _ = require('lodash');
const url = require('url');
const querystring = require('querystring');
function getWebServer() {
return getServer(webServerPackageName);
}
const DefaultEmailTextTemplate =
`%USERNAME%:
You have requested to enable 2-Factor Authentication via One-Time-Password
for your account on %BOARDNAME%.
* If this was not you, please ignore this email and change your password.
* Otherwise, please follow the link below:
%REGISTER_URL%
`;
module.exports = class User2FA_OTPWebRegister
{
static startup(cb) {
return User2FA_OTPWebRegister.registerRoutes(cb);
}
static sendRegisterEmail(user, otpType, cb) {
async.waterfall(
[
(callback) => {
return createToken(
user.userId,
WellKnownTokenTypes.AuthFactor2OTPRegister,
{ bits : 128 },
callback
);
},
(token, callback) => {
const config = Config();
const txtTemplateFile = _.get(config, 'users.twoFactorAuth.otp.registerEmailText');
const htmlTemplateFile = _.get(config, 'users.twoFactorAuth.otp.registerEmailHtml');
fs.readFile(txtTemplateFile, 'utf8', (err, textTemplate) => {
textTemplate = textTemplate || DefaultEmailTextTemplate;
fs.readFile(htmlTemplateFile, 'utf8', (err, htmlTemplate) => {
htmlTemplate = htmlTemplate || null; // be explicit for waterfall
return callback(null, token, textTemplate, htmlTemplate);
});
});
},
(token, textTemplate, htmlTemplate, callback) => {
const webServer = getWebServer();
const registerUrl = webServer.instance.buildUrl(
`/enable_2fa_otp?token=${token}&otpType=${otpType}`
);
const replaceTokens = (s) => {
return s
.replace(/%BOARDNAME%/g, Config().general.boardName)
.replace(/%USERNAME%/g, user.username)
.replace(/%TOKEN%/g, token)
.replace(/%REGISTER_URL%/g, registerUrl)
;
};
textTemplate = replaceTokens(textTemplate);
if(htmlTemplate) {
htmlTemplate = replaceTokens(htmlTemplate);
}
const message = {
to : `${user.getProperty(UserProps.RealName) || user.username} <${user.getProperty(UserProps.EmailAddress)}>`,
// from will be filled in
subject : '2-Factor Authentication Registration',
text : textTemplate,
html : htmlTemplate,
};
sendMail(message, (err, info) => {
if(err) {
Log.warn({ error : err.message }, 'Failed sending 2FA/OTP register email');
} else {
Log.info({ info }, 'Successfully sent 2FA/OTP register email');
}
return callback(err);
});
}
],
err => {
return cb(err);
}
);
}
static fileNotFound(webServer, resp) {
return webServer.instance.fileNotFound(resp);
}
static accessDenied(webServer, resp) {
return webServer.instance.accessDenied(resp);
}
static routeRegisterGet(req, resp) {
const webServer = getWebServer(); // must be valid, we just got a req!
const urlParts = url.parse(req.url, true);
const token = urlParts.query && urlParts.query.token;
const otpType = urlParts.query && urlParts.query.otpType;
if(!token || !otpType) {
return User2FA_OTPWebRegister.accessDenied(webServer, resp);
}
getTokenInfo(token, (err, tokenInfo) => {
if(err) {
// assume expired
return webServer.instance.respondWithError(
resp,
410,
'Invalid or expired registration link.', 'Expired Link'
);
}
if(tokenInfo.tokenType !== WellKnownTokenTypes.AuthFactor2OTPRegister) {
return User2FA_OTPWebRegister.accessDenied(webServer, resp);
}
const prepareOptions = {
qrType : 'data',
cellSize : 8,
username : tokenInfo.user.username,
};
prepareOTP(otpType, prepareOptions, (err, otpInfo) => {
if(err) {
Log.error({ error : err.message }, 'Failed to prepare OTP');
return User2FA_OTPWebRegister.accessDenied(webServer, resp);
}
const postUrl = webServer.instance.buildUrl('/enable_2fa_otp');
const config = Config();
return webServer.instance.routeTemplateFilePage(
_.get(config, 'users.twoFactorAuth.otp.registerPageTemplate'),
(templateData, next) => {
const finalPage = templateData
.replace(/%BOARDNAME%/g, config.general.boardName)
.replace(/%USERNAME%/g, tokenInfo.user.username)
.replace(/%TOKEN%/g, token)
.replace(/%OTP_TYPE%/g, otpType)
.replace(/%POST_URL%/g, postUrl)
.replace(/%QR_IMG_DATA%/g, otpInfo.qr || '')
.replace(/%SECRET%/g, otpInfo.secret)
;
return next(null, finalPage);
},
resp
);
});
});
}
static routeRegisterPost(req, resp) {
const webServer = getWebServer(); // must be valid, we just got a req!
const badRequest = () => {
return webServer.instance.respondWithError(resp, 400, 'Bad Request.', 'Bad Request');
};
let bodyData = '';
req.on('data', data => {
bodyData += data;
});
req.on('end', () => {
const formData = querystring.parse(bodyData);
if(!formData.token || !formData.otpType || !formData.otp ||
!formData.secret)
{
return badRequest();
}
const otp = otpFromType(formData.otpType);
if(!otp) {
return badRequest();
}
const valid = otp.verify( { token : formData.otp, secret : formData.secret } );
if(!valid) {
return User2FA_OTPWebRegister.accessDenied(webServer, resp);
}
getTokenInfo(formData.token, (err, tokenInfo) => {
if(err) {
return User2FA_OTPWebRegister.accessDenied(webServer, resp);
}
const backupCodes = createBackupCodes();
const props = {
[ UserProps.AuthFactor2OTP ] : formData.otpType,
[ UserProps.AuthFactor2OTPSecret ] : formData.secret,
[ UserProps.AuthFactor2OTPBackupCodes ] : JSON.stringify(backupCodes),
};
tokenInfo.user.persistProperties(props, err => {
if(err) {
return webServer.instance.respondWithError(resp, 500, 'Internal Server Error', 'Internal Server Error');
}
//
// User may be online still - find account & update it if so
//
const clientConn = getConnectionByUserId(tokenInfo.user.userId);
if(clientConn && clientConn.user) {
// just update live props, we've already persisted them.
_.each(props, (v, n) => {
clientConn.user.setProperty(n, v);
});
}
// we can now remove the token - no need to wait
deleteToken(formData.token, err => {
if(err) {
Log.error({error : err.message, token : formData.token}, 'Failed to delete temporary token');
}
});
// :TODO: use a html template here too, if provided
resp.writeHead(200);
return resp.end(
`2-Factor Authentication via One-Time-Password has been enabled for this account. Please write down your backup codes and store them in safe place:
${backupCodes}
`
);
});
});
});
}
static registerRoutes(cb) {
const webServer = getWebServer();
if(!webServer || !webServer.instance.isEnabled()) {
return cb(null); // no webserver enabled
}
[
{
method : 'GET',
path : '^\\/enable_2fa_otp\\?token\\=[a-f0-9]+&otpType\\=[a-zA-Z0-9_]+$',
handler : User2FA_OTPWebRegister.routeRegisterGet,
},
{
method : 'POST',
path : '^\\/enable_2fa_otp$',
handler : User2FA_OTPWebRegister.routeRegisterPost,
}
].forEach(r => {
webServer.instance.addRoute(r);
});
return cb(null);
}
};

View File

@ -76,12 +76,12 @@ exports.getModule = class UserConfigModule extends MenuModule {
},
validatePassConfirmMatch : function(data, cb) {
var passwordView = self.getView(MciCodeIds.Password);
var passwordView = self.getMenuView(MciCodeIds.Password);
cb(passwordView.getData() === data ? null : new Error('Passwords do not match'));
},
viewValidationListener : function(err, cb) {
var errMsgView = self.getView(MciCodeIds.ErrorMsg);
var errMsgView = self.getMenuView(MciCodeIds.ErrorMsg);
var newFocusId;
if(errMsgView) {
if(err) {
@ -89,7 +89,7 @@ exports.getModule = class UserConfigModule extends MenuModule {
if(err.view.getId() === MciCodeIds.PassConfirm) {
newFocusId = MciCodeIds.Password;
var passwordView = self.getView(MciCodeIds.Password);
var passwordView = self.getMenuView(MciCodeIds.Password);
passwordView.clearText();
err.view.clearText();
}
@ -150,7 +150,7 @@ exports.getModule = class UserConfigModule extends MenuModule {
};
}
getView(viewId) {
getMenuView(viewId) {
return this.viewControllers.menu.getView(viewId);
}
@ -200,13 +200,13 @@ exports.getModule = class UserConfigModule extends MenuModule {
self.setViewText('menu', MciCodeIds.TermHeight, user.properties[UserProps.TermHeight].toString());
var themeView = self.getView(MciCodeIds.Theme);
var themeView = self.getMenuView(MciCodeIds.Theme);
if(themeView) {
themeView.setItems(_.map(self.availThemeInfo, 'name'));
themeView.setFocusItemIndex(currentThemeIdIndex);
}
var realNameView = self.getView(MciCodeIds.RealName);
var realNameView = self.getMenuView(MciCodeIds.RealName);
if(realNameView) {
realNameView.setFocus(true); // :TODO: HACK! menu.hjson sets focus, but manual population above breaks this. Needs a real fix!
}

View File

@ -15,14 +15,32 @@ const {
const UserProps = require('./user_property.js');
const SysProps = require('./system_property.js');
const SystemLogKeys = require('./system_log.js');
const User = require('./user.js');
const {
getMessageConferenceByTag,
getMessageAreaByTag,
getSuitableMessageConfAndAreaTags,
} = require('./message_area.js');
const {
getFileAreaByTag,
getDefaultFileAreaTag,
} = require('./file_base_area.js');
// deps
const async = require('async');
const _ = require('lodash');
const assert = require('assert');
exports.userLogin = userLogin;
exports.userLogin = userLogin;
exports.recordLogin = recordLogin;
exports.transformLoginError = transformLoginError;
function userLogin(client, username, password, options, cb) {
if(!cb && _.isFunction(options)) {
cb = options;
options = {};
}
function userLogin(client, username, password, cb) {
const config = Config();
if(config.users.badUserNames.includes(username.toLowerCase())) {
@ -34,22 +52,23 @@ function userLogin(client, username, password, cb) {
}, 2000);
}
client.user.authenticate(username, password, err => {
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);
}
const authInfo = {
username,
password,
};
client.log.info( { username, ip : client.remoteAddress, reason : err.message }, 'Failed login attempt');
return cb(err);
authInfo.type = options.authType || User.AuthFactor1Types.Password;
authInfo.pubKey = options.ctx;
client.user.authenticateFactor1(authInfo, err => {
if(err) {
return cb(transformLoginError(err, client, username));
}
const user = client.user;
// Good login; reset any failed attempts
delete user.sessionFailedLoginAttempts;
delete client.sessionFailedLoginAttempts;
//
// Ensure this user is not already logged in.
@ -90,41 +109,113 @@ function userLogin(client, username, password, cb) {
Events.emit(Events.getSystemEvents().UserLogin, { user } );
async.parallel(
[
function setTheme(callback) {
setClientTheme(client, user.properties[UserProps.ThemeId]);
return callback(null);
},
function updateSystemLoginCount(callback) {
StatLog.incrementNonPersistentSystemStat(SysProps.LoginsToday, 1);
return StatLog.incrementSystemStat(SysProps.LoginCount, 1, callback);
},
function recordLastLogin(callback) {
return StatLog.setUserStat(user, UserProps.LastLoginTs, StatLog.now, callback);
},
function updateUserLoginCount(callback) {
return StatLog.incrementUserStat(user, UserProps.LoginCount, 1, callback);
},
function recordLoginHistory(callback) {
const loginHistoryMax = Config().statLog.systemEvents.loginHistoryMax;
const historyItem = JSON.stringify({
userId : user.userId,
sessionId : user.sessionId,
});
setClientTheme(client, user.properties[UserProps.ThemeId]);
return StatLog.appendSystemLogEntry(
SystemLogKeys.UserLoginHistory,
historyItem,
loginHistoryMax,
StatLog.KeepType.Max,
callback
);
}
],
err => {
postLoginPrep(client, err => {
if(err) {
return cb(err);
}
);
if(user.authenticated) {
return recordLogin(client, cb);
}
// recordLogin() must happen after 2FA!
return cb(null);
});
});
}
function postLoginPrep(client, cb) {
async.series(
[
(callback) => {
//
// User may (no longer) have read (view) rights to their current
// message, conferences and/or areas. Move them out if so.
//
const confTag = client.user.getProperty(UserProps.MessageConfTag);
const conf = getMessageConferenceByTag(confTag) || {};
const area = getMessageAreaByTag(client.user.getProperty(UserProps.MessageAreaTag), confTag) || {};
if(!client.acs.hasMessageConfRead(conf) || !client.acs.hasMessageAreaRead(area)) {
// move them out of both area and possibly conf to something suitable, hopefully.
const [newConfTag, newAreaTag] = getSuitableMessageConfAndAreaTags(client);
client.user.persistProperties({
[ UserProps.MessageConfTag ] : newConfTag,
[ UserProps.MessageAreaTag ] : newAreaTag,
},
err => {
return callback(err);
});
} else {
return callback(null);
}
},
(callback) => {
// Likewise for file areas
const area = getFileAreaByTag(client.user.getProperty(UserProps.FileAreaTag)) || {};
if(!client.acs.hasFileAreaRead(area)) {
const areaTag = getDefaultFileAreaTag(client) || '';
client.user.persistProperty(UserProps.FileAreaTag, areaTag, err => {
return callback(err);
});
} else {
return callback(null);
}
}
],
err => {
return cb(err);
}
);
}
function recordLogin(client, cb) {
assert(client.user.authenticated); // don't get in situations where this isn't true
const user = client.user;
async.parallel(
[
(callback) => {
StatLog.incrementNonPersistentSystemStat(SysProps.LoginsToday, 1);
return StatLog.incrementSystemStat(SysProps.LoginCount, 1, callback);
},
(callback) => {
return StatLog.setUserStat(user, UserProps.LastLoginTs, StatLog.now, callback);
},
(callback) => {
return StatLog.incrementUserStat(user, UserProps.LoginCount, 1, callback);
},
(callback) => {
const loginHistoryMax = Config().statLog.systemEvents.loginHistoryMax;
const historyItem = JSON.stringify({
userId : user.userId,
sessionId : user.sessionId,
});
return StatLog.appendSystemLogEntry(
SystemLogKeys.UserLoginHistory,
historyItem,
loginHistoryMax,
StatLog.KeepType.Max,
callback
);
}
],
err => {
return cb(err);
}
);
}
function transformLoginError(err, client, username) {
client.sessionFailedLoginAttempts = _.get(client, 'sessionFailedLoginAttempts', 0) + 1;
const disconnect = Config().users.failedLogin.disconnect;
if(disconnect > 0 && client.sessionFailedLoginAttempts >= disconnect) {
err = Errors.BadLogin('To many failed login attempts', ErrorReasons.TooMany);
}
client.log.info( { username, ip : client.remoteAddress, reason : err.message }, 'Failed login attempt');
return err;
}

View File

@ -8,54 +8,61 @@
// can utilize their own properties as well!
//
module.exports = {
PassPbkdf2Salt : 'pw_pbkdf2_salt',
PassPbkdf2Dk : 'pw_pbkdf2_dk',
PassPbkdf2Salt : 'pw_pbkdf2_salt',
PassPbkdf2Dk : 'pw_pbkdf2_dk',
AccountStatus : 'account_status', // See User.AccountStatus enum
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
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
AutoSignature : 'auto_signature',
DownloadQueue : 'dl_queue', // download_queue.js
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
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',
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',
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',
MessageConfTag : 'message_conf_tag',
MessageAreaTag : 'message_area_tag',
MessagePostCount : 'post_count',
DoorRunTotalCount : 'door_run_total_count',
DoorRunTotalMinutes : 'door_run_total_minutes',
DoorRunTotalCount : 'door_run_total_count',
DoorRunTotalMinutes : 'door_run_total_minutes',
AchievementTotalCount : 'achievement_total_count',
AchievementTotalPoints : 'achievement_total_points',
AchievementTotalCount : 'achievement_total_count',
AchievementTotalPoints : 'achievement_total_points',
MinutesOnlineTotalCount : 'minutes_online_total_count',
MinutesOnlineTotalCount : 'minutes_online_total_count',
SSHPubKey : 'ssh_public_key', // OpenSSH format (ssh-keygen, etc.)
AuthFactor1Types : 'auth_factor1_types', // List of User.AuthFactor1Types value(s)
AuthFactor2OTP : 'auth_factor2_otp', // If present, OTP type for 2FA. See OTPTypes
AuthFactor2OTPSecret : 'auth_factor2_otp_secret', // Secret used in conjunction with OTP 2FA
AuthFactor2OTPBackupCodes : 'auth_factor2_otp_backup', // JSON array of backup codes
};

140
core/user_temp_token.js Normal file
View File

@ -0,0 +1,140 @@
/* jslint node: true */
'use strict';
// ENiGMA½
const UserDb = require('./database.js').dbs.user;
const {
getISOTimestampString
} = require('./database.js');
const { Errors } = require('./enig_error.js');
const User = require('./user.js');
const Log = require('./logger.js').log;
// deps
const crypto = require('crypto');
const async = require('async');
const moment = require('moment');
exports.createToken = createToken;
exports.deleteToken = deleteToken;
exports.deleteTokenByUserAndType = deleteTokenByUserAndType;
exports.getTokenInfo = getTokenInfo;
exports.temporaryTokenMaintenanceTask = temporaryTokenMaintenanceTask;
exports.WellKnownTokenTypes = {
AuthFactor2OTPRegister : 'auth_factor2_otp_register',
};
function createToken(userId, tokenType, options = { bits : 128 }, cb) {
async.waterfall(
[
(callback) => {
return crypto.randomBytes(options.bits, callback);
},
(token, callback) => {
token = token.toString('hex');
UserDb.run(
`INSERT OR REPLACE INTO user_temporary_token (user_id, token, token_type, timestamp)
VALUES (?, ?, ?, ?);`,
[ userId, token, tokenType, getISOTimestampString() ],
err => {
return callback(err, token);
}
);
}
],
(err, token) => {
return cb(err, token);
}
);
}
function deleteToken(token, cb) {
UserDb.run(
`DELETE FROM user_temporary_token
WHERE token = ?;`,
[ token ],
err => {
return cb(err);
}
);
}
function deleteTokenByUserAndType(userId, tokenType, cb) {
UserDb.run(
`DELETE FROM user_temporary_token
WHERE user_id = ? AND token_type = ?;`,
[ userId, tokenType ],
err => {
return cb(err);
}
);
}
function getTokenInfo(token, cb) {
async.waterfall(
[
(callback) => {
UserDb.get(
`SELECT user_id, token_type, timestamp
FROM user_temporary_token
WHERE token = ?;`,
[ token ],
(err, row) => {
if(err) {
return callback(err);
}
if(!row) {
return callback(Errors.DoesNotExist('No entry found for token'));
}
const info = {
userId : row.user_id,
tokenType : row.token_type,
timestamp : moment(row.timestamp),
};
return callback(null, info);
}
);
},
(info, callback) => {
User.getUser(info.userId, (err, user) => {
info.user = user;
return callback(err, info);
});
}
],
(err, info) => {
return cb(err, info);
}
);
}
function temporaryTokenMaintenanceTask(args, cb) {
const tokenType = args[0];
if(!tokenType) {
return Log.error('Cannot run temporary token maintenance task with out specifying "tokenType" as argument 0');
}
const expTime = args[1] || '24 hours';
UserDb.run(
`DELETE FROM user_temporary_token
WHERE token IN (
SELECT token
FROM user_temporary_token
WHERE token_type = ?
AND DATETIME("now") >= DATETIME(timestamp, "+${expTime}")
);`,
[ tokenType ],
err => {
if(err) {
Log.warn( { error : err.message, tokenType }, 'Failed deleting user temporary token');
}
return cb(err);
}
);
}

View File

@ -178,6 +178,12 @@ View.prototype.setSpecialKeyMapOverride = function(specialKeyMapOverride) {
View.prototype.setPropertyValue = function(propName, value) {
switch(propName) {
case 'acceptsFocus' :
if (_.isBoolean(value)) {
this.acceptsFocus = value;
}
break;
case 'height' : this.setHeight(value); break;
case 'width' : this.setWidth(value); break;
case 'focus' : this.setFocus(value); break;
@ -220,6 +226,12 @@ View.prototype.setPropertyValue = function(propName, value) {
case 'argName' : this.submitArgName = value; break;
case 'omit' :
if(_.isBoolean(value)) {
this.omitFromSubmission = value; break;
}
break;
case 'validate' :
if(_.isFunction(value)) {
this.validate = value;

View File

@ -46,6 +46,8 @@ function ViewController(options) {
return; // ignore until this is finished!
}
self.client.log.trace( { actionBlock }, 'Action match' );
self.waitActionCompletion = true;
menuUtil.handleAction(self.client, formData, actionBlock, (err) => {
if(err) {
@ -121,9 +123,7 @@ function ViewController(options) {
self.emit('submit', this.getFormData(key));
};
// :TODO: replace this in favor of overriding toJSON() for various things such that logging will *never* output them
this.getLogFriendlyFormData = function(formData) {
// :TODO: these fields should be part of menu.json sensitiveMembers[]
var safeFormData = _.cloneDeep(formData);
if(safeFormData.value.password) {
safeFormData.value.password = '*****';
@ -219,7 +219,7 @@ function ViewController(options) {
break;
default :
propValue = propValue = conf[propName];
propValue = conf[propName];
break;
}
} else {
@ -330,15 +330,6 @@ function ViewController(options) {
}
}
}
self.client.log.trace(
{
formValue : formValue,
actionValue : actionValue
},
'Action match'
);
return true;
};
@ -577,7 +568,7 @@ ViewController.prototype.loadFromPromptConfig = function(options, cb) {
if(false === self.noInput) {
self.on('submit', function promptSubmit(formData) {
self.client.log.trace( { formData : self.getLogFriendlyFormData(formData) }, 'Prompt submit');
self.client.log.trace( { formData }, 'Prompt submit');
const doSubmitNotify = () => {
if(options.submitNotify) {
@ -752,8 +743,7 @@ ViewController.prototype.loadFromMenuConfig = function(options, cb) {
}
self.on('submit', function formSubmit(formData) {
self.client.log.trace( { formData : self.getLogFriendlyFormData(formData) }, 'Form submit');
self.client.log.trace( { formData }, 'Form submit');
//
// Locate configuration for this form ID
@ -870,6 +860,11 @@ ViewController.prototype.getFormData = function(key) {
return;
}
// some form values may be omitted from submission all together
if(view.omitFromSubmission) {
return;
}
viewData = view.getData();
if(_.isUndefined(viewData)) {
return;

View File

@ -22,7 +22,7 @@ const _ = require('lodash');
const PW_RESET_EMAIL_TEXT_TEMPLATE_DEFAULT =
`%USERNAME%:
a password reset has been requested for your account on %BOARDNAME%.
A password reset has been requested for your account on %BOARDNAME%.
* If this was not you, please ignore this email.
* Otherwise, follow this link: %RESET_URL%
@ -133,7 +133,7 @@ class WebPasswordReset {
if(err) {
Log.warn( { error : err.message }, 'Failed sending password reset email' );
} else {
Log.debug( { info : info }, 'Successfully sent password reset email');
Log.info( { info : info }, 'Successfully sent password reset email');
}
return callback(err);

View File

@ -24,6 +24,7 @@
- [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 %})
- [Security]({{ site.baseurl }}{% link configuration/security.md %})
- File Base
- [About]({{ site.baseurl }}{% link filebase/index.md %})
@ -81,6 +82,8 @@
- [Set Newscan Date]({{ site.baseurl }}{% link modding/set-newscan-date.md %})
- [Node to Node Messaging]({{ site.baseurl }}{% link modding/node-msg.md %})
- [Top X]({{ site.baseurl }}{% link modding/top-x.md %})
- [2FA/OTP Config]({{ site.baseurl }}{% link modding/user-2fa-otp-config.md %})
- [Auto Signature Editor]({{ site.baseurl }}{% link modding/autosig-edit.md %})
- Administration
- [oputil]({{ site.baseurl }}{% link admin/oputil.md %})

View File

@ -9,17 +9,18 @@ Let's look the main help output as per this writing:
```
usage: oputil.js [--version] [--help]
<command> [<args>]
<command> [<arguments>]
global args:
-c, --config PATH specify config path (./config/)
-n, --no-prompt assume defaults/don't prompt for input where possible
Global arguments:
-c, --config PATH Specify config path (default is ./config/)
-n, --no-prompt Assume defaults (don't prompt for input where possible)
--verbose Verbose output, where applicable
commands:
user user utilities
config config file management
fb file base management
mb message base management
Commands:
user User management
config Configuration management
fb File base management
mb Message base management
```
Commands break up operations by groups:
@ -41,19 +42,55 @@ Type `./oputil.js <command> --help` for additional help on a particular command.
The `user` command covers various user operations.
```
usage: oputil.js user <action> [<args>]
usage: oputil.js user <action> [<arguments>]
actions:
info USERNAME display information about a user
pw USERNAME PASSWORD set a user's password
aliases: password, passwd
rm USERNAME permanently removes user from system
aliases: remove, delete, del
activate USERNAME set status to active
deactivate USERNAME set status to inactive
disable USERNAME set status to disabled
lock USERNAME set status to locked
group USERNAME [+|-]GROUP adds (+) or removes (-) user from a group
Actions:
info USERNAME Display information about a user
pw USERNAME PASSWORD Set a user's password
(passwd|password)
rm USERNAME Permanently removes user from system
(del|delete|remove)
rename USERNAME NEWNAME Rename a user
(mv)
2fa-otp USERNAME SPEC Enable 2FA/OTP for the user
(otp)
The system supports various implementations of Two Factor Authentication (2FA)
One Time Password (OTP) authentication.
Valid specs:
disable : Removes 2FA/OTP from the user
google : Google Authenticator
hotp : HMAC-Based One-Time Password Algorithm (RFC-4266)
totp : Time-Based One-Time Password Algorithm (RFC-6238)
activate USERNAME Set a user's status to "active"
deactivate USERNAME Set a user's status to "inactive"
disable USERNAME Set a user's status to "disabled"
lock USERNAME Set a user's status to "locked"
group USERNAME [+|-]GROUP Adds (+) or removes (-) user from a group
info arguments:
--security Include security information in output
2fa-otp arguments:
--qr-type TYPE Specify QR code type
Valid QR types:
ascii : Plain ASCII (default)
data : HTML data URL
img : HTML image tag
svg : SVG image
--out PATH Path to write QR code to. defaults to stdout
```
| Action | Description | Examples | Aliases |
@ -61,25 +98,31 @@ actions:
| `info` | Display user information| `./oputil.js user info joeuser` | N/A |
| `pw` | Set password | `./oputil.js user pw joeuser s3cr37` | `passwd`, `password` |
| `rm` | Removes user | `./oputil.js user del joeuser` | `remove`, `del`, `delete` |
| `rename` | Renames a user | `./oputil.js user rename joeuser joe` | `mv` |
| `2fa-otp` | Manage 2FA/OTP for a user | `./oputil.js user 2fa-otp joeuser googleAuth` | `otp`
| `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 |
#### Manage 2FA/OTP
While `oputil.js` can be used to manage a user's 2FA/OTP, it is highly recommended to require users to opt-in themselves. See [Security](/docs/configuration/security.md) for details.
## Configuration
The `config` command allows sysops to perform various system configuration and maintenance tasks.
```
usage: oputil.js config <action> [<args>]
usage: oputil.js config <action> [<arguments>]
actions:
new generate a new/initial configuration
cat cat current configuration to stdout
Actions:
new Generate a new / default configuration
cat args:
--no-color disable color
--no-comments strip any comments
cat Write current configuration to stdout
cat arguments:
--no-color Disable color
--no-comments Strip any comments
```
| Action | Description | Examples |
@ -91,56 +134,75 @@ cat args:
The `fb` command provides a powerful file base management interface.
```
usage: oputil.js fb <action> [<args>]
usage: oputil.js fb <action> [<arguments>]
actions:
scan AREA_TAG[@STORAGE_TAG] scan specified area
may also contain optional GLOB as last parameter,
for example: scan some_area *.zip
Actions:
scan AREA_TAG[@STORAGE_TAG] Scan specified area
info CRITERIA display information about areas and/or files
matching CRITERIA.
May contain optional GLOB as last parameter.
Example: ./oputil.js fb scan d0pew4r3z *.zip
mv SRC [SRC...] DST move entry(s) from SRC to DST
SRC: FILENAME_WC|SHA|FILE_ID|AREA_TAG[@STORAGE_TAG]
DST: AREA_TAG[@STORAGE_TAG]
info CRITERIA Display information about areas and/or files
rm SRC [SRC...] remove entry(s) from the system matching SRC
SRC: FILENAME_WC|SHA|FILE_ID|AREA_TAG[@STORAGE_TAG]
desc CRITERIA sets a new file description for file base entry
matching CRITERIA. Launches an external editor using
$VISUAL, $EDITOR, or vim/notepad.
import-areas FILEGATE.ZXX import file base areas using FileGate RAID type format
mv SRC [SRC...] DST Move matching entry(s)
(move)
scan args:
--tags TAG1,TAG2,... specify tag(s) to assign to discovered entries
Source may be any of the following:
- Filename including '*' wildcards
- SHA-1
- File ID
- Area tag with optional @storageTag suffix
Destination is area tag with optional @storageTag suffix
--desc-file [PATH] prefer file descriptions from supplied path over other
other sources such as FILE_ID.DIZ. Path must point to
a valid FILES.BBS or DESCRIPT.ION file.
--update attempt to update information for existing entries
--quick perform quick scan
rm SRC [SRC...] Remove entry(s) from the system
(del|delete|remove)
info args:
--show-desc display short description, if any
Source may be any of the following:
- Filename including '*' wildcards
- SHA-1
- File ID
- Area tag with optional @storageTag suffix
remove args:
--phys-file also remove underlying physical file
desc CRITERIA Updates an file base entry's description
import-areas args:
--type TYPE sets import areas type. valid options are "zxx" or "na"
--create-dirs create backing storage directories
Launches an external editor using $VISUAL, $EDITOR, or vim/notepad.
general information:
AREA_TAG[@STORAGE_TAG] can specify an area tag and optionally, a storage specific tag
example: retro@bbs
import-areas FILEGATE.ZXX Import file base areas using FileGate RAID type format
CRITERIA file base entry criteria. in general, can be AREA_TAG, SHA,
FILE_ID, or FILENAME_WC.
scan arguments:
--tags TAG1,TAG2,... Specify hashtag(s) to assign to discovered entries
FILENAME_WC filename with * and ? wildcard support. may match 0:n entries
SHA full or partial SHA-256
FILE_ID a file identifier. see file.sqlite3
--desc-file [PATH] Prefer file descriptions from supplied input file
If a file description can be found in the supplied input file, prefer that description
over other sources such related FILE_ID.DIZ. Path must point to a valid FILES.BBS or
DESCRIPT.ION file.
--update Attempt to update information for existing entries
--full Perform a full scan (default is quick)
info arguments:
--show-desc Display short description, if any
remove arguments:
--phys-file Also remove underlying physical file
import-areas arguments:
--type TYPE Sets import areas type
Valid types are are "zxx" or "na".
--create-dirs Also create backing storage directories
General Information:
Generally an area tag can also include an optional storage tag. For example, the
area of 'bbswarez' stored using 'bbswarez_main': bbswarez@bbswarez_main
When performing an initial import of a large area or storage backing, --full
is the best option. If re-scanning an area for updates a standard / quick scan is
generally good enough.
File ID's are those found in file.sqlite3.
```
#### Scan File Area
@ -149,7 +211,8 @@ The `scan` action can (re)scan a file area for new entries as well as update (`-
##### Examples
Performing a quick scan of a specific area's storage location ("retro_warez", "retro_warez_games) matching only *.zip extensions:
```bash
$ ./oputil.js fb scan --quick retro_warez@retro_warez_games *.zip`
# note that we must quote the wildcard to prevent shell expansion
$ ./oputil.js fb scan --quick retro_warez@retro_warez_games "*.zip"`
```
Update all entries in the "artscene" area supplying the file tags "artscene", and "textmode".
@ -218,19 +281,23 @@ The above command will process FILEGATE.ZXX creating areas and backing directori
The `mb` command provides various Message Base related tools:
```
usage: oputil.js mb <action> [<args>]
usage: oputil.js mb <action> [<arguments>]
actions:
areafix CMD1 CMD2 ... ADDR sends an AreaFix NetMail to ADDR with the supplied command(s)
one or more commands may be supplied. commands that are multi
part such as "%COMPRESS ZIP" should be quoted.
import-areas PATH import areas using fidonet *.NA or AREAS.BBS file from PATH
Actions:
areafix CMD1 CMD2 ... ADDR Sends an AreaFix NetMail
import-areas args:
--conf CONF_TAG conference tag in which to import areas
--network NETWORK network name/key to associate FTN areas
--uplinks UL1,UL2,... one or more comma separated uplinks
--type TYPE area import type. valid options are "bbs" and "na"
NetMail is sent to supplied address with the supplied command(s). Multi-part commands
such as "%COMPRESS ZIP" should be quoted.
import-areas PATH Import areas using FidoNet *.NA or AREAS.BBS file
import-areas arguments:
--conf CONF_TAG Conference tag in which to import areas
--network NETWORK Network name/key to associate FTN areas
--uplinks UL1,UL2,... One or more uplinks (comma separated)
--type TYPE Area import type
Valid types are "bbs" and "na"
```
| Action | Description | Examples |

View File

@ -16,8 +16,8 @@ for a full listing. Many codes attempt to pay homage to Oblivion/2, iNiQUiTY, et
| Code | Description |
|------|--------------|
| `BN` | Board Name |
| `VL` | Version *label*, e.g. "ENiGMA½ v0.0.9-alpha" |
| `VN` | Version *number*, eg.. "0.0.9-alpha" |
| `VL` | Version *label*, e.g. "ENiGMA½ v0.0.10-alpha" |
| `VN` | Version *number*, eg.. "0.0.10-alpha" |
| `SN` | SysOp username |
| `SR` | SysOp real name |
| `SL` | SysOp location |

View File

@ -37,8 +37,8 @@ The following are ACS codes available as of this writing:
| MM<i>minutes</i> | It is currently >= _minutes_ past midnight (system time) |
| AC<i>achievementCount</i> | User has >= _achievementCount_ achievements |
| AP<i>achievementPoints</i> | User has >= _achievementPoints_ achievement points |
\* Many more ACS codes are planned for the near future.
| AF<i>authFactor</i> | User's current *Authentication Factor* is >= _authFactor_. Authentication factor 1 refers to username + password (or PubKey) while factor 2 refers to 2FA such as One-Time-Password authentication. |
| AR<i>authFactorReq</i> | Current user **requires** an Authentication Factor >= _authFactorReq_ |
## ACS Strings
ACS strings are one or more ACS codes in addition to some basic language semantics.

View File

@ -11,9 +11,15 @@ Archivers are manged via the `archives:archivers` configuration block of `config
The following archivers are pre-configured in ENiGMA½ as of this writing. Remember that you can override settings or add new handlers!
#### ZZip
* Formats: .7z, .bzip2, .zip, .gzip/.gz, and more
* Formats: .7z, .bzip2, .gzip/.gz, and more
* Key: `7Zip`
* Homepage/package: [7-zip.org](http://www.7-zip.org/). Generally obtained from a `p7zip` package in UNIX-like environments. See http://p7zip.sourceforge.net/ for details.
* Notes: Versions previous to 0.0.10-alpha defaulted to using 7zip for .zip files as well, but newer versions of the package give "volume" errors at times. See InfoZip below.
#### InfoZip
* Formats: .zip
* Key: InfoZip
* Homepage/package: http://infozip.sourceforge.net/. Often already available in Linux. You will need `zip` and `unzip` in ENiGMA's path.
#### Lha
* Formats: <a href="https://en.wikipedia.org/wiki/LHA_(file_format)">LHA</a> files such as .lzh.

View File

@ -74,6 +74,23 @@ Submit actions are declared using the `action` member of a submit handler block.
| `@method:methodName` | Executes *methodName* local to the calling module. That is, the module set by the `module` member of a menu entry. |
| `@method:/path/to/some_module.js:methodName` | Executes *methodName* exported by the module at */path/to/some_module.js*. |
#### Advanced Action Handling
In addition to simple simple actions, `action` may also be:
* An array of objects containing ACS checks and a sub `action` if that ACS is matched. See **Action Matches** in the ACS documentation below for details.
* An array of actions. In this case a random selection will be made. Example:
```hjson
submit: [
{
value: { command: "FOO" }
action: [
// one of the following actions will be matched:
"@menu:menuStyle1"
"@menu:menuStyle2"
]
}
]
```
#### Method Signature
Methods executed using `@method`, or `@systemMethod` have the following signature:
```
@ -86,6 +103,7 @@ Many built in global/system methods exist. Below are a few. See [system_menu_met
| Method | Description |
|--------|-------------|
| `login` | Performs a standard login. |
| `login2FA_OTP` | Performs a 2-Factor Authentication (2FA) One-Time Password (OTP) check, if configured for the user. |
| `logoff` | Performs a standard system logoff. |
| `prevMenu` | Goes to the previous menu. |
| `nextMenu` | Goes to the next menu (as set by `next`) |
@ -179,6 +197,23 @@ opOnlyMenu: {
}
```
### Action Matches
Action blocks (`action`) can perform ACS checks:
```
// ...
{
action: [
{
acs: SC1
action: @menu:secureMenu
}
{
action: @menu:nonSecureMenu
}
]
}
```
### Flow Control
The `next` member of a menu may be an array of objects containing an `acs` check as well as the destination. Depending on the current user's ACS, the system will pick the appropriate target. The last element in an array without an `acs` can be used as a catch all. Example:
```

View File

@ -0,0 +1,64 @@
---
layout: page
title: Security
---
## Security
Unlike in the golden era of BBSing, modern Internet-connected systems are prone to hacking attempts, eavesdropping, etc. While plain-text passwords, insecure data over [Plain Old Telephone Service (POTS)](https://en.wikipedia.org/wiki/Plain_old_telephone_service), and so on was good enough then, modern systems must employ protections against attacks. ENiGMA½ comes with many security features that help keep the system and your users secure — not limited to:
* Passwords are **never** stored in plain-text, but instead are stored using [Password-Based Key Derivation Function 2 (PBKDF2)](https://en.wikipedia.org/wiki/PBKDF2). Even the system operator can _never_ know your password!
* Alternatives to insecure Telnet logins are built in: [SSH](https://en.wikipedia.org/wiki/Secure_Shell) and secure [WebSockets](https://en.wikipedia.org/wiki/WebSocket) for example.
* A built in web server with [TLS](https://en.wikipedia.org/wiki/Transport_Layer_Security) support (aka HTTPS).
* Optional [Two-Factor Authentication (2FA)](https://en.wikipedia.org/wiki/Multi-factor_authentication) via [One-Time-Password (OTP)](https://en.wikipedia.org/wiki/One-time_password) for users, supporting [Google Authenticator](http://google-authenticator.com/), [Time-Based One-Time Password Algorithm (TOTP, RFC-6238)](https://tools.ietf.org/html/rfc6238), and [HMAC-Based One-Time Password Algorithm (HOTP, RFC-4266)](https://tools.ietf.org/html/rfc4226).
## Two-Factor Authentication via One-Time Password
Enabling Two-Factor Authentication via One-Time-Password (2FA/OTP) on an account adds an extra layer of security ("_something a user has_") in addition to their password ("_something a user knows_"). Providing 2FA/OTP to your users has some prerequisites:
* [A configured email gateway](/docs/configuration/email.md) such that the system can send out emails.
* One or more secure servers enabled such as [SSH](/docs/servers/ssh.md) or secure [WebSockets](/docs/servers/websocket.md) (that is, WebSockets over a secure connection such as TLS).
* The [web server](/docs/servers/web-server.md) enabled and exposed over TLS (HTTPS).
:information_source: For WebSockets and the web server, ENiGMA½ _may_ listen on insecure channels if behind a secure web proxy.
### User Registration Flow
Due to the nature of 2FA/OTP, even if enabled on your system, users must opt-in and enable this feature on their account. Users must also have a valid email address such that a registration link can be sent to them. To opt-in, users must enable the option, which will cause the system to email them a registration link. Following the link provides the following:
1. A secret for manual entry into a OTP device.
2. If applicable, a scannable QR code for easy device entry (e.g. Google Authenticator)
3. A confirmation prompt in which the user must enter a OTP code. If entered correctly, this validates everything is set up properly and 2FA/OTP will be enabled for the account. Backup codes will also be provided at this time. Future logins will now prompt the user for their OTP after they enter their standard password.
:warning: Serving 2FA/OTP registration links over insecure (HTTP) can expose secrets intended for the user and is **highly** discouraged!
:information_source: +ops can also manually enable or disable 2FA/OTP for a user using [oputil](/docs/admin/oputil.md), but this is generally discouraged.
#### Recovery
In the situation that a user loses their 2FA/OTP device (such as a lost phone with Google Auth), there are some options:
* Utilize one of their backup codes.
* Contact the SysOp.
:warning: There is no way for a user to disable 2FA/OTP without first fully logging in! This is by design as a security measure.
### ACS Checks
Various places throughout the system that implement [ACS](/docs/configuration/acs.md) can make 2FA specific checks:
* `AR#`: Current users **required** authentication factor. `AR2` for example means 2FA/OTP is required for this user.
* `AF#`: Current users **active** authentication factor. `AF2` means the user is authenticated with some sort of 2FA (such as One-Time-Password).
See [ACS](/docs/configuration/acs.md) for more information.
#### Example
The following example illustrates using an `AR` ACS check to require applicable users to go through an additional 2FA/OTP process during login:
```hjson
login: {
art: USERLOG
next: [
{
// users with AR2+ must first pass 2FA/OTP
acs: AR2
next: loginTwoFactorAuthOTP
}
{
// everyone else skips ahead
next: fullLoginSequenceLoginArt
}
]
// ...
}
```

View File

@ -6,10 +6,10 @@ title: Install Script
Under most Linux/UNIX like environments (Linux, BSD, OS X, ...) new users can simply execute the `install.sh` script to get everything up and running. Cut + paste the following into your terminal:
```
curl -o- https://raw.githubusercontent.com/NuSkooler/enigma-bbs/master/misc/install.sh | bash
curl -o- https://raw.githubusercontent.com/NuSkooler/enigma-bbs/0.0.10-alpha/misc/install.sh | bash
```
You may review the [installation script](https://raw.githubusercontent.com/NuSkooler/enigma-bbs/master/misc/install.sh)
You may review the [installation script](https://raw.githubusercontent.com/NuSkooler/enigma-bbs/0.0.10-alpha/misc/install.sh)
on GitHub before running it.
The script will install nvm, Node.js 6 and grab the latest ENiGMA BBS from GitHub. It will also guide you through creating a basic configuration file, and recommend some packages to install.

View File

@ -21,7 +21,8 @@ Each conference is represented by a entry under `messageConferences`. Each entri
### ACS
An optional standard [ACS](/docs/configuration/acs.md) block can be supplied with the following rules:
* `read`: ACS require to read (see) this conference. Defaults to `GM[users]`.
* `read`: ACS required to read (see) this conference. Defaults to `GM[users]`.
* `write`: ACS required to write (post) to this conference. Defaults to `GM[users]`.
### Example
@ -51,10 +52,12 @@ Message Areas are topic specific containers for messages that live within a part
| `sort` | :-1: | Set to a number to override the default alpha-numeric sort order based on the `name` field. |
| `default` | :-1: | Specify `true` to make this the default area (e.g. assigned to new users) |
| `acs` | :-1: | A standard [ACS](/docs/configuration/acs.md) block. See **ACS** below. |
| `autoSignatures` | :-1: | Set to `false` to disable auto-signatures in this area. |
### ACS
An optional standard [ACS](/docs/configuration/acs.md) block can be supplied with the following rules:
* `read`: ACS require to read (see) this conference. Defaults to `GM[users]`.
* `read`: ACS required to read (see) this area. Defaults to `GM[users]`.
* `write`: ACS required to write (post) to this area. Defaults to `GM[users]`.
### Example
@ -63,13 +66,14 @@ messageConferences: {
local: {
// ... see above ...
areas: {
enigma_dev: { // Area tag - required elsewhere!
enigma_dev: { // Area tag - required elsewhere!
name: ENiGMA 1/2 Development
desc: ENiGMA 1/2 discussion!
desc: ENiGMA 1/2 development and discussion!
sort: 1
default: true
acs: {
read: GM[users] // default
write: GM[l33t] // super elite ENiGMA 1/2 users!
}
}
}

View File

@ -0,0 +1,22 @@
---
layout: page
title: Auto Signature Editor
---
## The Auto Signature Editor
The built in `autosig_edit` module allows users to edit their auto signatures (AKA "autosig").
### Theming
The following MCI codes are available:
* MCI 1 (ie: `MT1`): Editor
* MCI 2 (ie: `BT2`): Save button
### Disabling Auto Signatures
Auto Signature support can be disabled for a particular message area by setting `autoSignatures` to false in the area's configuration block.
Example:
```hjson
my_area: {
name: My Area
autoSignatures: false
}
```

View File

@ -48,7 +48,7 @@ mciMap: {
}
```
### Theming
## Theming
Generally `mciMap` entries will point to a Vertical List View Menu (`%VM1`, `%VM2`, etc.). The following `itemFormat` object is provided:
* `value`: The value acquired from the supplied data source.
* `userName`: User's username.

View File

@ -0,0 +1,73 @@
---
layout: page
title: TopX
---
## The 2FA/OTP Config Module
The `user_2fa_otp_config` module provides opt-in, configuration, and viewing of Two-Factor Authentication via One-Time-Password (2FA/OTP) settings. In order to allow users access to 2FA/OTP, the system must be properly configured. See [Security](/docs/configuration/security.md) for more information.
:information_source: By default, the 2FA/OTP configuration menu may only be accessed by users connected securely (ACS `SC`). It is highly recommended to leave this default as accessing these settings over a plain-text connection could expose private secrets!
## Configuration
### Config Block
Available `config` block entries:
* `infoText`: Overrides default informational text string(s). See **Info Text** below.
* `statusText:` Overrides default status text string(s). See **Status Text** below.
Example:
```hjson
config: {
infoText: {
googleAuth: Google Authenticator available on mobile phones, etc.
}
statusText: {
saveError: Doh! Failed to save :(
}
}
```
#### Info Text (infoText)
Overrides default informational text relative to current selections. Available keys:
* `disabled`: Displayed when OTP switched to enabled.
* `enabled`: Displayed when OTP switched to disabled.
* `rfc6238_TOTP`: Describes TOTP.
* `rfc4266_HOTP`: Describes HOTP.
* `googleAuth`: Describes Google Authenticator OTP.
#### Status Text (statusText)
Overrides default status text for various conditions. Available keys:
* `otpNotEnabled`
* `noBackupCodes`
* `saveDisabled`
* `saveEmailSent`
* `saveError`
* `qrNotAvail`
* `emailRequired`
## Theming
The following MCI codes are available:
* MCI 1: (ie: `TM1`): Toggle 2FA/OTP enabled/disabled.
* MCI 2: (ie: `SM2`): 2FA/OTP type selection.
* MCI 3: (ie: `TM3`): Submit/cancel toggle.
* MCI 10...99: Custom entries with the following format members available:
* `{infoText}`: **Info Text** for current selection.
### Web and Email Templates
A template system is also available to customize registration emails and the landing page.
#### Emails
Multipart MIME emails are send built using template files pointed to by `users.twoFactorAuth.otp.registerEmailText` and `users.toFactorAuth.otp.registerEmailHtml` supporting the following variables:
* `%BOARDNAME%`: BBS name.
* `%USERNAME%`: Username receiving email.
* `%TOKEN%`: Temporary registration token generally used in URL.
* `%REGISTER_URL%`: Full registration URL.
#### Landing Page
The landing page template is pointed to by `users.twoFactorAuth.otp.registerPageTemplate` and supports the following variables:
* `%BOARDNAME%`: BBS name.
* `%USERNAME%`: Username receiving email.
* `%TOKEN%`: Temporary registration token generally used in URL.
* `%OTP_TYPE%`: OTP type such as `googleAuth`.
* `%POST_URL%`: URL to POST form to.
* `%QR_IMG_DATA%`: QR code in URL image data format. Not always available depending on OTP type and will be set to blank in these cases.
* `%SECRET%`: Secret for manual entry.

View File

@ -2,6 +2,7 @@
{
const UserProps = require('./user_property.js');
const Log = require('./logger.js').log;
const User = require('./user.js');
const _ = require('lodash');
const moment = require('moment');
@ -138,6 +139,22 @@
SC : function isSecureConnection() {
return _.get(client, 'session.isSecure', false);
},
AF : function currentAuthFactor() {
if(!user) {
return false;
}
return !isNaN(value) && user.authFactor >= value;
},
AR : function authFactorRequired() {
if(!user) {
return false;
}
switch(value) {
case 1 : return true;
case 2 : return user.getProperty(UserProps.AuthFactor2OTP) ? true : false;
default : return false;
}
},
ML : function minutesLeft() {
// :TODO: implement me!
return false;

View File

@ -50,6 +50,21 @@
general: {
// Your BBS Name!
boardName: XXXXX
// Your BBS name, with pipe codes for styling
prettyBoardName : '|08XXXXX'
// Telnet hostname and port for your board
telnetHostname : 'xibalba.l33t.codes:44510'
// SSH hostname and port for your board
sshHostname : 'xibalba.l33t.codes:44511'
// Your board's website
website : 'https://enigma-bbs.github.io'
// Short board description
description : 'Yet another awesome ENiGMA½ BBS'
}
paths: {
@ -118,10 +133,10 @@
//
// > openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \
// -pkeyopt rsa_keygen_pubexp:65537 | openssl rsa \
// -out ./config/ssh_private_key.pem -aes128
// -out ./config/security/ssh_private_key.pem -aes128
//
// (The above is a more modern equivelant of the following):
// > openssl genrsa -aes128 -out ./config/ssh_private_key.pem 2048
// > openssl genrsa -aes128 -out ./config/security/ssh_private_key.pem 2048
//
// 2 - Set 'privateKeyPass' to the password you used in step #1
//
@ -274,6 +289,16 @@
}
}
chatServers: {
// multi relay chat settings. No need to sign up, just enable it.
// More info: https://bbswiki.bottomlessabyss.net/index.php?title=MRC_Chat_platform
mrc: {
enabled : false
serverHostname : 'mrc.bottomlessabyss.net'
serverPort : 5000
}
}
//
// Currently, ENiGMA½ can use external email to mail
// users for password resets. Additional functionality will

View File

@ -3,7 +3,7 @@
{ # this ensures the entire script is downloaded before execution
ENIGMA_NODE_VERSION=${ENIGMA_NODE_VERSION:=10}
ENIGMA_BRANCH=master
ENIGMA_BRANCH=${ENIGMA_BRANCH:=master}
ENIGMA_INSTALL_DIR=${ENIGMA_INSTALL_DIR:=$HOME/enigma-bbs}
ENIGMA_SOURCE=${ENIGMA_SOURCE:=https://github.com/NuSkooler/enigma-bbs.git}
TIME_FORMAT=`date "+%Y-%m-%d %H:%M:%S"`
@ -21,7 +21,7 @@ _____________________ _____ ____________________ __________\\_ /
/__ _\\
<*> ENiGMA½ // https://github.com/NuSkooler/enigma-bbs <*> /__/
ENiGMA½ will be installed to ${ENIGMA_INSTALL_DIR}, from source ${ENIGMA_SOURCE}.
ENiGMA½ will be installed to ${ENIGMA_INSTALL_DIR}, from source ${ENIGMA_SOURCE}, branch ${ENIGMA_BRANCH}.
ENiGMA½ requires Node.js. Version ${ENIGMA_NODE_VERSION}.x current will be installed via nvm. If you already have nvm installed, this install script will update it to the latest version.
@ -37,6 +37,7 @@ fatal_error() {
}
enigma_install_needs() {
echo "Checking $1 installation"
command -v $1 >/dev/null 2>&1 || fatal_error "ENiGMA½ requires $1 but it's not installed. Please install it and restart the installer."
}
@ -45,14 +46,11 @@ log() {
}
enigma_install_init() {
log "Checking git installation"
enigma_install_needs git
log "Checking curl installation"
enigma_install_needs curl
log "Checking Python installation"
enigma_install_needs python
enigma_install_needs make
enigma_install_needs gcc
}
install_nvm() {

View File

@ -60,11 +60,20 @@
//
// SSH connections are pre-authenticated via the SSH server itself.
// Jump directly to the login sequence
// Jump directly to either the 2FA/OTP auth or the login sequence
// depending on user ACS.
//
sshConnected: {
art: CONNECT
next: fullLoginSequenceLoginArt
next: [
{
acs: AR2
next: loginTwoFactorAuthOTPLoop
}
{
next: fullLoginSequenceLoginArt
}
]
config: { nextTimeout: 1500 }
}
@ -90,11 +99,6 @@
submit: true
focus: true
argName: navSelect
//
// To enable forgot password, you will need to have the web server
// enabled and mail/SMTP configured. Once that is in place, swap out
// the commented lines below as well as in the submit block
//
items: [
{
text: login
@ -104,10 +108,20 @@
text: apply
data: apply
}
//
// To enable the forgot password option, you'll need to have
// the web server & email configured. Once that is in place,
// uncomment the section below.
//
// See docs for more information
//
/*
{
text: forgot pass
data: forgot
}
*/
{
text: log off
data: logoff
@ -142,7 +156,20 @@
login: {
art: USERLOG
next: fullLoginSequenceLoginArt
next: [
{
//
// Users with 2FA/OTP enabled *must* go through
// an additional OTP authentication step
//
acs: AR2
next: loginTwoFactorAuthOTPLoop
}
{
// ...everyone else can carry on as per usual
next: fullLoginSequenceLoginArt
}
]
config: {
tooNodeMenu: loginAttemptTooNode
inactive: loginAttemptAccountInactive
@ -218,6 +245,46 @@
next: logoff
}
//
// Empty menu to catch us in a 2FA/OTP auth loop
// until the user either authenticates successfully
// or the system boots them.
//
loginTwoFactorAuthOTPLoop: {
next: loginTwoFactorAuthOTP
}
loginTwoFactorAuthOTP: {
art: 2FAOTP
next: fullLoginSequenceLoginArt
form: {
0: {
mci: {
ET1: {
argName: token
focus: true
submit: true
}
}
submit: {
*: [
{
value: { token: null }
action: @systemMethod:login2FA_OTP
}
]
}
actionKeys: [
{
// no turning back at this point...
keys: [ "escape" ]
action: @systemMethod:logoff
}
]
}
}
}
forgotPassword: {
desc: Forgot password
prompt: forgotPasswordPrompt
@ -549,6 +616,7 @@
argName: to
focus: true
text: @sysStat:sysop_username
maxLength: 36
// :TODO: readOnly: true
}
ET3: {
@ -1066,6 +1134,27 @@
value: { command: "UA" }
action: @menu:mainMenuUserAchievementsEarned
}
{
value: { command: "MRC" }
action: @menu:mrc
}
{
value: { command: "2FA" }
action: [
{
//
// For security reasons, only allow 2FA/OTP to be
// configured over already secure (SSL, wss://, ...)
// connections. Not doing so risks leaking secrets!
//
acs: SC
action: @menu:userTwoFactorAuthOTPConfig
}
{
action: @menu:userTwoFactorAuthOTPSecConnRequired
}
]
}
{
value: 1
action: @menu:mainMenu
@ -1094,6 +1183,143 @@
}
}
mrc: {
desc: MRC Chat
module: mrc
art: MRC
config: {
cls: true
// max lines kept in scrollback buffer
maxScrollbackLines: 500
}
form: {
0: {
mci: {
MT1: {
mode: preview
autoScroll: true
}
ET2: {
argName: inputArea
submit: true
focus: true
}
}
actionKeys: [
{
keys: [ "escape" ]
action: @systemMethod:prevMenu
}
{
keys: [ "down arrow", "up arrow", "page up", "page down" ]
action: @method:movementKeyPressed
}
]
submit: {
*: [
{
value: { inputArea: null }
action: @method:sendChatMessage
}
]
}
}
}
}
userTwoFactorAuthOTPConfig: {
desc: 2FA/OTP Config
module: user_2fa_otp_config
art: 2FACONFSCR
form: {
0: {
mci: {
TM1: {
argName: enableToggle
focus: true
items: [
// order is important here:
"disable"
"enable/reset"
]
}
SM2: {
argName: otpType
items: [
// order is important here:
"Time-Based - TOTP"
"HMAC-Based - HOTP"
"Google Authenticator"
]
}
TM3: {
argName: submit
items: [
"save"
"cancel"
]
submit: true
}
}
submit: {
*: [
{
value: { submit: 0 }
action: @method:saveChanges
}
{
value: { submit: 1 }
action: @systemMethod:prevMenu
}
]
}
actionKeys: [
{
keys: [ "escape" ]
action: @systemMethod:prevMenu
}
{
keys: [ "q", "shift + q" ]
action: @method:showQRCode
}
{
keys: [ "s", "shift + s" ]
action: @method:showSecret
}
{
keys: [ "b", "shift + b" ]
action: @method:showBackupCodes
}
{
keys: [ "n", "shift + n" ]
action: @method:generateNewBackupCodes
}
]
}
}
}
userTwoFactorAuthOTPSecConnRequired: {
desc: Insecure Warning
art: 2FAOTPSECREQ
config: {
cls: true
pause: true
}
}
userTwoFactorAuthOTPConfigShowDetails: {
desc: 2FA/OTP Details
module: show_art
config: {
pause: true
method: extraArgs
}
}
nodeMessage: {
desc: Node Messaging
module: node_msg
@ -1307,6 +1533,7 @@
argName: to
focus: true
text: @sysStat:sysop_username
maxLength: 36
// :TODO: readOnly: true
}
ET3: {
@ -1863,6 +2090,14 @@
value: { command: "S" }
action: @menu:messageSearch
}
{
value: { command: "M" }
action: @menu:myMessages
}
{
value: { command: "A" }
action: @menu:editAutoSignature
}
{
value: 1
action: @menu:messageArea
@ -1870,6 +2105,42 @@
]
}
editAutoSignature: {
desc: Auto Sig Editor
module: autosig_edit
art: autosig
form: {
0: {
mci: {
MT1: {
argName: signature
tabSwitchesView: true
}
BT2: {
text: save
argName: save
submit: true
}
}
submit: {
*: [
{
value: { save: null }
action: @method:saveChanges
}
]
}
actionKeys: [
{
keys: [ "escape" ]
action: @systemMethod:prevMenu
}
]
}
}
}
messageSearch: {
desc: Message Search
module: message_base_search
@ -1970,6 +2241,48 @@
}
}
myMessages: {
desc: Personal Messages
module: my_messages
config: {
messageListMenu: messageAreaMyMessagesList
}
}
messageAreaMyMessagesList: {
desc: Personal Messages
module: msg_list
art: MYMSGLST
config: {
menuViewPost: messageAreaViewPost
}
form: {
0: {
mci: {
VM1: {
focus: true
submit: true
argName: message
}
}
submit: {
*: [
{
value: { message: null }
action: @method:selectMessage
}
]
}
actionKeys: [
{
keys: [ "escape", "q", "shift + q" ]
action: @systemMethod:prevMenu
}
]
}
}
}
messageSearchNoResults: {
desc: Message Search
art: MSRCNORES
@ -2275,6 +2588,7 @@
argName: to
focus: true
validate: @systemMethod:validateNonEmpty
maxLength: 36
}
ET3: {
argName: subject
@ -2433,6 +2747,7 @@
focus: true
text: All
validate: @systemMethod:validateNonEmpty
maxLength: 36
}
ET3: {
argName: subject
@ -2592,6 +2907,7 @@
argName: to
focus: true
validate: @systemMethod:validateGeneralMailAddressedTo
maxLength: 36
}
ET3: {
argName: subject

View File

@ -0,0 +1,11 @@
<b>%USERNAME%</b>:<br>
<br>
<p>
You have requested to enable 2-Factor Authentication via One-Time-Password for your account on <b>%BOARDNAME%</b>.<br>
</p>
<p>
If this was not you, please ignore this email and <u>consider changing your password</u>. Otherwise, <a href="%REGISTER_URL%">please follow this link</a> or copy and paste the link below:<br><br>
%REGISTER_URL%<br>
</p>

View File

@ -0,0 +1,7 @@
%USERNAME%:
You have requested to enable 2-Factor Authentication via One-Time-Password for your account on %BOARDNAME%.
If this was not you, please ignore this email and consider changing your password. Otherwise, please follow the link below:
%REGISTER_URL%

View File

@ -1,6 +1,6 @@
{
"name": "enigma-bbs",
"version": "0.0.9-alpha",
"version": "0.0.10-alpha",
"description": "ENiGMA½ Bulletin Board System",
"author": "Bryan Ashby <bryan@l33t.codes>",
"license": "BSD-2-Clause",
@ -22,38 +22,40 @@
"retro"
],
"dependencies": {
"async": "^2.6.1",
"binary-parser": "^1.3.2",
"async": "3.1.0",
"binary-parser": "^1.5.0",
"buffers": "github:NuSkooler/node-buffers",
"bunyan": "^1.8.12",
"exiftool": "^0.0.3",
"fs-extra": "^7.0.1",
"glob": "^7.1.2",
"graceful-fs": "^4.1.15",
"hashids": "^1.1.1",
"hjson": "^3.1.2",
"iconv-lite": "^0.4.23",
"inquirer": "^6.2.1",
"fs-extra": "8.1.0",
"glob": "7.1.6",
"graceful-fs": "^4.2.3",
"hashids": "^2.0.1",
"hjson": "^3.2.1",
"iconv-lite": "0.5.0",
"inquirer": "^7.0.0",
"later": "1.2.0",
"lodash": "^4.17.10",
"lodash": "^4.17.15",
"lru-cache": "^5.1.1",
"mime-types": "^2.1.21",
"minimist": "1.2.x",
"mime-types": "2.1.24",
"minimist": "1.2.0",
"moment": "^2.24.0",
"nntp-server": "^1.0.3",
"node-pty": "^0.8.1",
"nodemailer": "^5.1.1",
"node-pty": "^0.9.0",
"nodemailer": "^6.3.1",
"otplib": "11.0.1",
"qrcode-generator": "^1.4.4",
"rlogin": "^1.0.0",
"sane": "^4.0.2",
"sanitize-filename": "^1.6.1",
"sqlite3": "^4.0.6",
"sane": "4.1.0",
"sanitize-filename": "^1.6.3",
"sqlite3": "^4.1.0",
"sqlite3-trans": "^1.2.1",
"ssh2": "^0.8.2",
"ssh2": "0.8.6",
"temptmp": "^1.1.0",
"uuid": "^3.2.1",
"uuid-parse": "^1.0.0",
"ws": "^6.1.3",
"xxhash": "^0.2.4",
"uuid": "^3.3.3",
"uuid-parse": "1.1.0",
"ws": "^7.2.0",
"xxhash": "^0.3.0",
"yazl": "^2.5.1"
},
"devDependencies": {},

View File

@ -0,0 +1,29 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Enable 2FA/OTP — ENiGMA½ BBS</title>
<meta name="description" content="Enable 2-Factor Authentication via One-Time-Password">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<p>
Your OTP secret:<br>
<b>%SECRET%</b>
</p>
<script>
if('googleAuth' == '%OTP_TYPE%') {
document.write('QR Code:<br>');
document.write('<img src="%QR_IMG_DATA%"/>');
}
</script>
<form action="%POST_URL%" method="post">
<legend>Confirm One-Time-Password to continue:</legend>
<input type="text" placeholder="One Time Password" id="otp" name="otp" required>
<input type="hidden" value="%TOKEN%" name="token">
<input type="hidden" value="%OTP_TYPE%" name="otpType">
<input type="hidden" value="%SECRET%" name="secret">
<button type="submit">Confirm</button>
</form>
</body>
</html>

448
yarn.lock
View File

@ -2,6 +2,14 @@
# yarn lockfile v1
"@cnakazawa/watch@^1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@cnakazawa/watch/-/watch-1.0.3.tgz#099139eaec7ebf07a27c1786a3ff64f39464d2ef"
integrity sha512-r5160ogAvGyHsal38Kux7YYtodEKOj89RGb28ht1jh3SJb08VwRwAKKJL0bGb04Zd/3r9FL3BFIc3bBidYffCA==
dependencies:
exec-sh "^0.3.2"
minimist "^1.2.0"
abbrev@1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8"
@ -17,10 +25,12 @@ ajv@^5.3.0:
fast-json-stable-stringify "^2.0.0"
json-schema-traverse "^0.3.0"
ansi-escapes@^3.0.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-3.1.0.tgz#f73207bb81207d75fd6c83f125af26eea378ca30"
integrity sha512-UgAb8H9D41AQnu/PbWlCofQVcnV4Gs2bBJi9eZPxfU/hgglFh3SMDMENRIqdr7H6XFnXdoknctFByVsCOotTVw==
ansi-escapes@^4.2.1:
version "4.2.1"
resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.2.1.tgz#4dccdb846c3eee10f6d64dea66273eab90c37228"
integrity sha512-Cg3ymMAdN10wOk/VYfLV7KCQyv7EDirJ64500sU7n9UlmioEtDuU5Gd+hj73hXSU/ex7tHJSssmyftDdkMLO8Q==
dependencies:
type-fest "^0.5.2"
ansi-regex@^2.0.0:
version "2.1.1"
@ -32,10 +42,10 @@ ansi-regex@^3.0.0:
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998"
integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=
ansi-regex@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.0.0.tgz#70de791edf021404c3fd615aa89118ae0432e5a9"
integrity sha512-iB5Dda8t/UqpPI/IjsejXu5jOGDrzn41wJyljwPH65VCIbk6+1BzFIMJGFwTNrYXT1CrD+B4l19U7awiQ8rk7w==
ansi-regex@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.0.tgz#8b9f8f08cf1acb843756a839ca8c7e3168c51997"
integrity sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==
ansi-styles@^3.2.1:
version "3.2.1"
@ -114,17 +124,15 @@ assign-symbols@^1.0.0:
resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367"
integrity sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=
async-limiter@~1.0.0:
async-limiter@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.0.tgz#78faed8c3d074ab81f22b4e985d79e8738f720f8"
integrity sha512-jp/uFnooOiO+L211eZOoSyzpOITMXx1rBITauYykG3BRYPu8h0UcxsPNB04RR5vo4Tyz3+ay17tR6JVf9qzYWg==
async@^2.6.1:
version "2.6.1"
resolved "https://registry.yarnpkg.com/async/-/async-2.6.1.tgz#b245a23ca71930044ec53fa46aa00a3e87c6a610"
integrity sha512-fNEiL2+AZt6AlAw/29Cr0UDe4sRAHCpEHh54WMz+Bb7QfNcFw4h3loofyJpLeQs4Yx7yuqu/2dLgM5hKOs6HlQ==
dependencies:
lodash "^4.17.10"
async@3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/async/-/async-3.1.0.tgz#42b3b12ae1b74927b5217d8c0016baaf62463772"
integrity sha512-4vx/aaY6j/j3Lw3fbCHNWP0pPaTCew3F6F3hYyl/tHs/ndmV1q7NW9T5yuJ2XAGwdQrP+6Wu20x06U4APo/iQQ==
asynckit@^0.4.0:
version "0.4.0"
@ -171,10 +179,10 @@ bcrypt-pbkdf@^1.0.0, bcrypt-pbkdf@^1.0.2:
dependencies:
tweetnacl "^0.14.3"
binary-parser@^1.3.2:
version "1.3.2"
resolved "https://registry.yarnpkg.com/binary-parser/-/binary-parser-1.3.2.tgz#5bd04f948ada1a6d78c528762308a9a335d63db9"
integrity sha512-VDhHcpeF1/ZZy1XvDmYD67bBjRNm1gacw+772xNd5BnTH6ax5TzlDV5dl7216/UlQXQoN9vug07ehk7e0PhNUw==
binary-parser@^1.5.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/binary-parser/-/binary-parser-1.5.0.tgz#3e50de3a5076badbacd760e833e7d94892b9e9fa"
integrity sha512-z+hqNSnO7trFDPLihjUGTwlSTbcIzLYSCwnbiasFkRvCIY9F3ZTex7Mlm9UAP3w5mfHD3KxejnWFPJjtsVVMuw==
brace-expansion@^1.1.7:
version "1.1.11"
@ -241,22 +249,22 @@ cache-base@^1.0.1:
union-value "^1.0.0"
unset-value "^1.0.0"
capture-exit@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/capture-exit/-/capture-exit-1.2.0.tgz#1c5fcc489fd0ab00d4f1ac7ae1072e3173fbab6f"
integrity sha1-HF/MSJ/QqwDU8ax64QcuMXP7q28=
capture-exit@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/capture-exit/-/capture-exit-2.0.0.tgz#fb953bfaebeb781f62898239dabb426d08a509a4"
integrity sha512-PiT/hQmTonHhl/HFGN+Lx3JJUznrVYJ3+AQsnthneZbvW7x+f08Tk7yLJTLEOUvBTbduLeeBkxEaYXUOUrRq6g==
dependencies:
rsvp "^3.3.3"
rsvp "^4.8.4"
caseless@~0.12.0:
version "0.12.0"
resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=
chalk@^2.0.0:
version "2.4.1"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.1.tgz#18c49ab16a037b6eb0152cc83e3471338215b66e"
integrity sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ==
chalk@^2.4.2:
version "2.4.2"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==
dependencies:
ansi-styles "^3.2.1"
escape-string-regexp "^1.0.5"
@ -282,12 +290,12 @@ class-utils@^0.3.5:
isobject "^3.0.0"
static-extend "^0.1.1"
cli-cursor@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-2.1.0.tgz#b35dac376479facc3e94747d41d0d0f5238ffcb5"
integrity sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU=
cli-cursor@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307"
integrity sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==
dependencies:
restore-cursor "^2.0.0"
restore-cursor "^3.1.0"
cli-width@^2.0.0:
version "2.2.0"
@ -479,6 +487,11 @@ ecc-jsbn@~0.1.1:
jsbn "~0.1.0"
safer-buffer "^2.1.0"
emoji-regex@^8.0.0:
version "8.0.0"
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==
end-of-stream@^1.1.0, end-of-stream@^1.4.0:
version "1.4.1"
resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.1.tgz#ed29634d19baba463b6ce6b80a37213eab71ec43"
@ -491,13 +504,6 @@ escape-string-regexp@^1.0.5:
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=
exec-sh@^0.2.0:
version "0.2.2"
resolved "https://registry.yarnpkg.com/exec-sh/-/exec-sh-0.2.2.tgz#2a5e7ffcbd7d0ba2755bdecb16e5a427dfbdec36"
integrity sha512-FIUCJz1RbuS0FKTdaAafAByGS0CPvU3R0MeHxgtl+djzCc//F8HakL8GzmVNZanasTbTAY/3DRFA0KpVqj/eAw==
dependencies:
merge "^1.2.0"
exec-sh@^0.3.2:
version "0.3.2"
resolved "https://registry.yarnpkg.com/exec-sh/-/exec-sh-0.3.2.tgz#6738de2eb7c8e671d0366aea0b0db8c6f7d7391b"
@ -554,7 +560,7 @@ extend@~3.0.2:
resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==
external-editor@^3.0.0:
external-editor@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-3.0.3.tgz#5866db29a97826dbe4bf3afd24070ead9ea43a27"
integrity sha512-bn71H9+qWoOQKyZDo25mOMVpSmXROAsTJVVVYzrrtol3d4y+AsKjf4Iwl2Q+IuT0kFSQ1qo166UuIwqYq7mGnA==
@ -604,10 +610,10 @@ fb-watchman@^2.0.0:
dependencies:
bser "^2.0.0"
figures@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/figures/-/figures-2.0.0.tgz#3ab1a2d2a62c8bfb431a0c94cb797a2fce27c962"
integrity sha1-OrGi0qYsi/tDGgyUy3l6L84nyWI=
figures@^3.0.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/figures/-/figures-3.1.0.tgz#4b198dd07d8d71530642864af2d45dd9e459c4ec"
integrity sha512-ravh8VRXqHuMvZt/d8GblBeqDMkdJMBdv/2KntFH+ra5MXkO7nxNKpzQ3n6QD/2da1kH0aWmNISdvhM7gl2gVg==
dependencies:
escape-string-regexp "^1.0.5"
@ -655,12 +661,12 @@ from2@^2.3.0:
inherits "^2.0.1"
readable-stream "^2.0.0"
fs-extra@^7.0.1:
version "7.0.1"
resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-7.0.1.tgz#4f189c44aa123b895f722804f55ea23eadc348e9"
integrity sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==
fs-extra@8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0"
integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==
dependencies:
graceful-fs "^4.1.2"
graceful-fs "^4.2.0"
jsonfile "^4.0.0"
universalify "^0.1.0"
@ -709,6 +715,18 @@ getpass@^0.1.1:
dependencies:
assert-plus "^1.0.0"
glob@7.1.6:
version "7.1.6"
resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6"
integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==
dependencies:
fs.realpath "^1.0.0"
inflight "^1.0.4"
inherits "2"
minimatch "^3.0.4"
once "^1.3.0"
path-is-absolute "^1.0.0"
glob@^6.0.1:
version "6.0.4"
resolved "https://registry.yarnpkg.com/glob/-/glob-6.0.4.tgz#0f08860f6a155127b2fadd4f9ce24b1aab6e4d22"
@ -743,16 +761,21 @@ globby@^6.1.0:
pify "^2.0.0"
pinkie-promise "^2.0.0"
graceful-fs@^4.1.15:
version "4.1.15"
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.15.tgz#ffb703e1066e8a0eeaa4c8b80ba9253eeefbfb00"
integrity sha512-6uHUhOPEBgQ24HM+r6b/QwWfZq+yiFcipKFrOFiBEnWdy5sdzYoi+pJeQaPI5qOLRFqWmAXUPQNsielzdLoecA==
graceful-fs@^4.1.2, graceful-fs@^4.1.6:
graceful-fs@^4.1.6:
version "4.1.11"
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.11.tgz#0e8bdfe4d1ddb8854d64e04ea7c00e2a026e5658"
integrity sha1-Dovf5NHduIVNZOBOp8AOKgJuVlg=
graceful-fs@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.0.tgz#8d8fdc73977cb04104721cb53666c1ca64cd328b"
integrity sha512-jpSvDPV4Cq/bgtpndIWbI5hmYxhQGHPC4d4cqBPb4DLniCfhJokdXhwhaDuLBGLQdvvRum/UiX6ECVIPvDXqdg==
graceful-fs@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.3.tgz#4a12ff1b60376ef09862c2093edd908328be8423"
integrity sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ==
har-schema@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92"
@ -807,15 +830,15 @@ has-values@^1.0.0:
is-number "^3.0.0"
kind-of "^4.0.0"
hashids@^1.1.1:
version "1.2.2"
resolved "https://registry.yarnpkg.com/hashids/-/hashids-1.2.2.tgz#28635c7f2f7360ba463686078eee837479e8eafb"
integrity sha512-dEHCG2LraR6PNvSGxosZHIRgxF5sNLOIBFEHbj8lfP9WWmu/PWPMzsip1drdVSOFi51N2pU7gZavrgn7sbGFuw==
hashids@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/hashids/-/hashids-2.0.1.tgz#cdee93b6de1a6f941c955084fe82658c4fba28da"
integrity sha512-Hr1lPEGhCkZSniYj9jAj8gQtcTBKNhf/tF14R1tu3zvIcwCT89/ucHEyObSIUZ42Y30SlIfOt7Sq0Uw+rhs6iQ==
hjson@^3.1.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/hjson/-/hjson-3.1.2.tgz#1ae8a3a897a1fab8d45180f98e9abf9b56f95b55"
integrity sha512-2ILrho8eRl2Bniy61mDFiXRAloYqH2T6OwWkoF/8y55DPFgG2RcqQGNXIfBLp432dnAbLOpBJ4pJs63W3X27EA==
hjson@^3.2.1:
version "3.2.1"
resolved "https://registry.yarnpkg.com/hjson/-/hjson-3.2.1.tgz#20de41dc87fc9a10d1557d0230b0e02afb1b09ac"
integrity sha512-OhhrFMeC7dVuA1xvxuXGTv/yTdhTvbe8hz+3LgVNsfi9+vgz0sF/RrkuX8eegpKaMc9cwYwydImBH6iePoJtdQ==
http-signature@~1.2.0:
version "1.2.0"
@ -826,7 +849,14 @@ http-signature@~1.2.0:
jsprim "^1.2.2"
sshpk "^1.7.0"
iconv-lite@^0.4.23, iconv-lite@^0.4.24, iconv-lite@^0.4.4:
iconv-lite@0.5.0:
version "0.5.0"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.5.0.tgz#59cdde0a2a297cc2aeb0c6445a195ee89f127550"
integrity sha512-NnEhI9hIEKHOzJ4f697DMz9IQEXr/MMJ5w64vN2/4Ai+wRnvV7SBrL0KLoRlwaKVghOc7LQ5YkPLuX146b6Ydw==
dependencies:
safer-buffer ">= 2.1.2 < 3"
iconv-lite@^0.4.24, iconv-lite@^0.4.4:
version "0.4.24"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==
@ -858,23 +888,23 @@ ini@~1.3.0:
resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927"
integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==
inquirer@^6.2.1:
version "6.2.1"
resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-6.2.1.tgz#9943fc4882161bdb0b0c9276769c75b32dbfcd52"
integrity sha512-088kl3DRT2dLU5riVMKKr1DlImd6X7smDhpXUCkJDCKvTEJeRiXh0G132HG9u5a+6Ylw9plFRY7RuTnwohYSpg==
inquirer@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-7.0.0.tgz#9e2b032dde77da1db5db804758b8fea3a970519a"
integrity sha512-rSdC7zelHdRQFkWnhsMu2+2SO41mpv2oF2zy4tMhmiLWkcKbOAs87fWAJhVXttKVwhdZvymvnuM95EyEXg2/tQ==
dependencies:
ansi-escapes "^3.0.0"
chalk "^2.0.0"
cli-cursor "^2.1.0"
ansi-escapes "^4.2.1"
chalk "^2.4.2"
cli-cursor "^3.1.0"
cli-width "^2.0.0"
external-editor "^3.0.0"
figures "^2.0.0"
lodash "^4.17.10"
mute-stream "0.0.7"
external-editor "^3.0.3"
figures "^3.0.0"
lodash "^4.17.15"
mute-stream "0.0.8"
run-async "^2.2.0"
rxjs "^6.1.0"
string-width "^2.1.0"
strip-ansi "^5.0.0"
rxjs "^6.4.0"
string-width "^4.1.0"
strip-ansi "^5.1.0"
through "^2.3.6"
is-accessor-descriptor@^0.1.6:
@ -952,6 +982,11 @@ is-fullwidth-code-point@^2.0.0:
resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f"
integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=
is-fullwidth-code-point@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d"
integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==
is-number@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/is-number/-/is-number-3.0.0.tgz#24fd6201a4782cf50561c810276afc7d12d71195"
@ -1098,7 +1133,12 @@ later@1.2.0:
resolved "https://registry.yarnpkg.com/later/-/later-1.2.0.tgz#f2cf6c4dd7956dd2f520adf0329836e9876bad0f"
integrity sha1-8s9sTdeVbdL1IK3wMpg26YdrrQ8=
lodash@^4.17.10, lodash@^4.17.4:
lodash@^4.17.15:
version "4.17.15"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548"
integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==
lodash@^4.17.4:
version "4.17.11"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d"
integrity sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==
@ -1129,11 +1169,6 @@ map-visit@^1.0.0:
dependencies:
object-visit "^1.0.0"
merge@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/merge/-/merge-1.2.0.tgz#7531e39d4949c281a66b8c5a6e0265e8b05894da"
integrity sha1-dTHjnUlJwoGma4xabgJl6LBYlNo=
micromatch@^3.1.4:
version "3.1.10"
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23"
@ -1153,15 +1188,22 @@ micromatch@^3.1.4:
snapdragon "^0.8.1"
to-regex "^3.0.2"
mime-db@1.40.0:
version "1.40.0"
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.40.0.tgz#a65057e998db090f732a68f6c276d387d4126c32"
integrity sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA==
mime-db@~1.36.0:
version "1.36.0"
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.36.0.tgz#5020478db3c7fe93aad7bbcc4dcf869c43363397"
integrity sha512-L+xvyD9MkoYMXb1jAmzI/lWYAxAMCPvIBSWur0PZ5nOf5euahRLVqH//FKW9mWp2lkqUgYiXPgkzfMUFi4zVDw==
mime-db@~1.37.0:
version "1.37.0"
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.37.0.tgz#0b6a0ce6fdbe9576e25f1f2d2fde8830dc0ad0d8"
integrity sha512-R3C4db6bgQhlIhPU48fUtdVmKnflq+hRdad7IyKhtFj06VPNVdk2RhiYL3UjQIlso8L+YxAtFkobT0VK+S/ybg==
mime-types@2.1.24:
version "2.1.24"
resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.24.tgz#b6f8d0b3e951efb77dedeca194cff6d16f676f81"
integrity sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ==
dependencies:
mime-db "1.40.0"
mime-types@^2.1.12, mime-types@~2.1.19:
version "2.1.20"
@ -1170,17 +1212,10 @@ mime-types@^2.1.12, mime-types@~2.1.19:
dependencies:
mime-db "~1.36.0"
mime-types@^2.1.21:
version "2.1.21"
resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.21.tgz#28995aa1ecb770742fe6ae7e58f9181c744b3f96"
integrity sha512-3iL6DbwpyLzjR3xHSFNFeb9Nz/M8WDkX33t1GFQnFOllWk8pOrh/LSrB5OXlnlW5P9LH73X6loW/eogc+F5lJg==
dependencies:
mime-db "~1.37.0"
mimic-fn@^1.0.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.2.0.tgz#820c86a39334640e99516928bd03fca88057d022"
integrity sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==
mimic-fn@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b"
integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==
"minimatch@2 || 3", minimatch@^3.0.4:
version "3.0.4"
@ -1194,7 +1229,7 @@ minimist@0.0.8:
resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d"
integrity sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=
minimist@1.2.x, minimist@^1.1.1, minimist@^1.2.0:
minimist@1.2.0, minimist@^1.1.1, minimist@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284"
integrity sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=
@ -1249,10 +1284,10 @@ ms@^2.1.1:
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a"
integrity sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==
mute-stream@0.0.7:
version "0.0.7"
resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.7.tgz#3075ce93bc21b8fab43e1bc4da7e8115ed1e7bab"
integrity sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s=
mute-stream@0.0.8:
version "0.0.8"
resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d"
integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==
mv@~2:
version "2.1.1"
@ -1263,20 +1298,15 @@ mv@~2:
ncp "~2.0.0"
rimraf "~2.4.0"
nan@2.12.1:
version "2.12.1"
resolved "https://registry.yarnpkg.com/nan/-/nan-2.12.1.tgz#7b1aa193e9aa86057e3c7bbd0ac448e770925552"
integrity sha512-JY7V6lRkStKcKTvHO5NVSQRv+RV+FIL5pvDoLiAtSL9pKlC5x9PKQcZDsq7m4FO4d57mkhC6Z+QhAh3Jdk5JFw==
nan@^2.10.0, nan@^2.4.0:
nan@^2.10.0:
version "2.11.1"
resolved "https://registry.yarnpkg.com/nan/-/nan-2.11.1.tgz#90e22bccb8ca57ea4cd37cc83d3819b52eea6766"
integrity sha512-iji6k87OSXa0CcrLl9z+ZiYSuR2o+c0bGuNmXdrhTQTakxytAFsC56SArGYoiHlJlFoHSnvmhpceZJaXkVuOtA==
nan@~2.10.0:
version "2.10.0"
resolved "https://registry.yarnpkg.com/nan/-/nan-2.10.0.tgz#96d0cd610ebd58d4b4de9cc0c6828cda99c7548f"
integrity sha512-bAdJv7fBLhWC+/Bls0Oza+mvTaNQtP+1RyhhhvD95pgUJz6XM5IzgmxOkItJ9tkoCiplvAnXI1tNmmUD/eScyA==
nan@^2.12.1, nan@^2.13.2, nan@^2.14.0:
version "2.14.0"
resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.0.tgz#7818f722027b2459a86f0295d434d1fc2336c52c"
integrity sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg==
nanomatch@^1.2.9:
version "1.2.13"
@ -1351,17 +1381,17 @@ node-pre-gyp@^0.11.0:
semver "^5.3.0"
tar "^4"
node-pty@^0.8.1:
version "0.8.1"
resolved "https://registry.yarnpkg.com/node-pty/-/node-pty-0.8.1.tgz#94b457bec013e7a09b8d9141f63b0787fa25c23f"
integrity sha512-j+/g0Q5dR+vkELclpJpz32HcS3O/3EdPSGPvDXJZVJQLCvgG0toEbfmymxAEyQyZEpaoKHAcoL+PvKM+4N9nlw==
node-pty@^0.9.0:
version "0.9.0"
resolved "https://registry.yarnpkg.com/node-pty/-/node-pty-0.9.0.tgz#8f9bcc0d1c5b970a3184ffd533d862c7eb6590a6"
integrity sha512-MBnCQl83FTYOu7B4xWw10AW77AAh7ThCE1VXEv+JeWj8mSpGo+0bwgsV+b23ljBFwEM9OmsOv3kM27iUPPm84g==
dependencies:
nan "2.12.1"
nan "^2.14.0"
nodemailer@^5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-5.1.1.tgz#0c48d1ecab02e86d9ff6c620ee75ed944b763505"
integrity sha512-hKGCoeNdFL2W7S76J/Oucbw0/qRlfG815tENdhzcqTpSjKgAN91mFOqU2lQUflRRxFM7iZvCyaFcAR9noc/CqQ==
nodemailer@^6.3.1:
version "6.3.1"
resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.3.1.tgz#2784beebac6b9f014c424c54dbdcc5c4d1221346"
integrity sha512-j0BsSyaMlyadEDEypK/F+xlne2K5m6wzPYMXS/yxKI0s7jmT1kBx6GEKRVbZmyYfKOsjkeC/TiMVDJBI/w5gMQ==
nopt@^4.0.1:
version "4.0.1"
@ -1453,12 +1483,12 @@ once@^1.3.0, once@^1.3.1, once@^1.4.0:
dependencies:
wrappy "1"
onetime@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/onetime/-/onetime-2.0.1.tgz#067428230fd67443b2794b22bba528b6867962d4"
integrity sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ=
onetime@^5.1.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.0.tgz#fff0f3c91617fe62bb50189636e99ac8a6df7be5"
integrity sha512-5NcSkPHhwTVFIQN+TUqXoS5+dlElHXdpAWu9I0HP20YOtIi+aZ0Ct82jdlILDxjLEAWwvm+qj1m6aEtsDVmm6Q==
dependencies:
mimic-fn "^1.0.0"
mimic-fn "^2.1.0"
os-homedir@^1.0.0:
version "1.0.2"
@ -1478,6 +1508,13 @@ osenv@^0.1.4:
os-homedir "^1.0.0"
os-tmpdir "^1.0.0"
otplib@11.0.1:
version "11.0.1"
resolved "https://registry.yarnpkg.com/otplib/-/otplib-11.0.1.tgz#7d64aa87029f07c99c7f96819fb10cdb67dea886"
integrity sha512-oi57teljNyWTC/JqJztHOtSGeFNDiDh5C1myd+faocUtFAX27Sm1mbx69kpEJ8/JqrblI3kAm4Pqd6tZJoOIBQ==
dependencies:
thirty-two "1.0.2"
p-finally@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae"
@ -1563,6 +1600,11 @@ punycode@^1.4.1:
resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e"
integrity sha1-wNWmOycYgArY4esPpSachN1BhF4=
qrcode-generator@^1.4.4:
version "1.4.4"
resolved "https://registry.yarnpkg.com/qrcode-generator/-/qrcode-generator-1.4.4.tgz#63f771224854759329a99048806a53ed278740e7"
integrity sha512-HM7yY8O2ilqhmULxGMpcHSF1EhJJ9yBj8gvDEuZ6M+KGJ0YY2hKpnXvRD+hZPLrDVck3ExIGhmPtSdcjC+guuw==
qs@~6.5.2:
version "6.5.2"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"
@ -1654,12 +1696,12 @@ resolve-url@^0.2.1:
resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a"
integrity sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo=
restore-cursor@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-2.0.0.tgz#9f7ee287f82fd326d4fd162923d62129eee0dfaf"
integrity sha1-n37ih/gv0ybU/RYpI9YhKe7g368=
restore-cursor@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-3.1.0.tgz#39f67c54b3a7a58cea5236d95cf0034239631f7e"
integrity sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==
dependencies:
onetime "^2.0.0"
onetime "^5.1.0"
signal-exit "^3.0.2"
ret@~0.1.10:
@ -1686,10 +1728,10 @@ rlogin@^1.0.0:
resolved "https://registry.yarnpkg.com/rlogin/-/rlogin-1.0.0.tgz#db07322b31219126625d9d0aa9872d7ebe8ac403"
integrity sha1-2wcyKzEhkSZiXZ0KqYctfr6KxAM=
rsvp@^3.3.3:
version "3.6.2"
resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-3.6.2.tgz#2e96491599a96cde1b515d5674a8f7a91452926a"
integrity sha512-OfWGQTb9vnwRjwtA2QwpG2ICclHC3pgXZO5xt8H2EfgDquO0qVdSb5T88L4qJVAEugbS56pAuV4XZM58UX8ulw==
rsvp@^4.8.4:
version "4.8.5"
resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-4.8.5.tgz#c8f155311d167f68f21e168df71ec5b083113734"
integrity sha512-nfMOlASu9OnRJo1mbEk2cz0D56a1MBNrJ7orjRZQG10XDyuvwksKbuXNp6qa+kbn839HwjwhBzhFmdsaEAfauA==
run-async@^2.2.0:
version "2.3.0"
@ -1698,10 +1740,10 @@ run-async@^2.2.0:
dependencies:
is-promise "^2.1.0"
rxjs@^6.1.0:
version "6.3.3"
resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.3.3.tgz#3c6a7fa420e844a81390fb1158a9ec614f4bad55"
integrity sha512-JTWmoY9tWCs7zvIk/CvRjhjGaOd+OVBM987mxFo+OW66cGpdKjZcpmc74ES1sB//7Kl/PAe8+wEakuhG4pcgOw==
rxjs@^6.4.0:
version "6.4.0"
resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.4.0.tgz#f3bb0fe7bda7fb69deac0c16f17b50b0b8790504"
integrity sha512-Z9Yfa11F6B9Sg/BK9MnqnQ+aQYicPLtilXBp2yUtDt2JRCE0h26d33EnfO3ZxoNxG0T92OUucP3Ct7cpfkdFfw==
dependencies:
tslib "^1.9.0"
@ -1727,25 +1769,25 @@ safe-regex@^1.1.0:
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
sane@^4.0.2:
version "4.0.2"
resolved "https://registry.yarnpkg.com/sane/-/sane-4.0.2.tgz#5bd4a3f1268fd7a921a2dc657047de635c8f8f25"
integrity sha512-/3STCUfNSgMVpoREJc1i6ajKFlYZ5OflzZTOhlqPLa+01Ey+QR9iGZK7K5/qIRsQbEDCvqEJH/PL7yZywmnWsA==
sane@4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/sane/-/sane-4.1.0.tgz#ed881fd922733a6c461bc189dc2b6c006f3ffded"
integrity sha512-hhbzAgTIX8O7SHfp2c8/kREfEn4qO/9q8C9beyY6+tvZ87EpoZ3i1RIEvp27YBswnNbY9mWd6paKVmKbAgLfZA==
dependencies:
"@cnakazawa/watch" "^1.0.3"
anymatch "^2.0.0"
capture-exit "^1.2.0"
capture-exit "^2.0.0"
exec-sh "^0.3.2"
execa "^1.0.0"
fb-watchman "^2.0.0"
micromatch "^3.1.4"
minimist "^1.1.1"
walker "~1.0.5"
watch "~0.18.0"
sanitize-filename@^1.6.1:
version "1.6.1"
resolved "https://registry.yarnpkg.com/sanitize-filename/-/sanitize-filename-1.6.1.tgz#612da1c96473fa02dccda92dcd5b4ab164a6772a"
integrity sha1-YS2hyWRz+gLczaktzVtKsWSmdyo=
sanitize-filename@^1.6.3:
version "1.6.3"
resolved "https://registry.yarnpkg.com/sanitize-filename/-/sanitize-filename-1.6.3.tgz#755ebd752045931977e30b2025d340d7c9090378"
integrity sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==
dependencies:
truncate-utf8-bytes "^1.0.0"
@ -1883,30 +1925,30 @@ sqlite3-trans@^1.2.1:
dependencies:
lodash "^4.17.4"
sqlite3@^4.0.6:
version "4.0.6"
resolved "https://registry.yarnpkg.com/sqlite3/-/sqlite3-4.0.6.tgz#e587b583b5acc6cb38d4437dedb2572359c080ad"
integrity sha512-EqBXxHdKiwvNMRCgml86VTL5TK1i0IKiumnfxykX0gh6H6jaKijAXvE9O1N7+omfNSawR2fOmIyJZcfe8HYWpw==
sqlite3@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/sqlite3/-/sqlite3-4.1.0.tgz#e051fb9c133be15726322a69e2e37ec560368380"
integrity sha512-RvqoKxq+8pDHsJo7aXxsFR18i+dU2Wp5o12qAJOV5LNcDt+fgJsc2QKKg3sIRfXrN9ZjzY1T7SNe/DFVqAXjaw==
dependencies:
nan "~2.10.0"
nan "^2.12.1"
node-pre-gyp "^0.11.0"
request "^2.87.0"
ssh2-streams@~0.4.2:
version "0.4.2"
resolved "https://registry.yarnpkg.com/ssh2-streams/-/ssh2-streams-0.4.2.tgz#bac0d18727396d16049f5f0c8517a46516b45719"
integrity sha512-2rSj3oTIJnbAIzR3+XwIYef9wCOVrPQZNLL+fFPPjnPxf09tKkAbgrlYgh/1qynBTz65AUOS+s1zuko4M/GKCw==
ssh2-streams@~0.4.7:
version "0.4.7"
resolved "https://registry.yarnpkg.com/ssh2-streams/-/ssh2-streams-0.4.7.tgz#093b89069de9cf5f06feff0601a5301471b01611"
integrity sha512-JhF8BNfeguOqVHOLhXjzLlRKlUP8roAEhiT/y+NcBQCqpRUupLNrRf2M+549OPNVGx21KgKktug4P3MY/IvTig==
dependencies:
asn1 "~0.2.0"
bcrypt-pbkdf "^1.0.2"
streamsearch "~0.1.2"
ssh2@^0.8.2:
version "0.8.2"
resolved "https://registry.yarnpkg.com/ssh2/-/ssh2-0.8.2.tgz#f7a172458d3a7a13d520438264f90de8a3ee72af"
integrity sha512-oaXu7faddvPFGavnLBkk0RFwLXvIzCPq6KqAC3ExlnFPAVIE1uo7pWHe9xmhNHXm+nIe7yg9qsssOm+ip2jijw==
ssh2@0.8.6:
version "0.8.6"
resolved "https://registry.yarnpkg.com/ssh2/-/ssh2-0.8.6.tgz#dcc62e1d3b9e58a21f711f5186f043e4e792e6da"
integrity sha512-T0cPmEtmtC8WxSupicFDjx3vVUdNXO8xu2a/D5bjt8ixOUCe387AgvxU3mJgEHpu7+Sq1ZYx4d3P2pl/yxMH+w==
dependencies:
ssh2-streams "~0.4.2"
ssh2-streams "~0.4.7"
sshpk@^1.7.0:
version "1.14.2"
@ -1946,7 +1988,7 @@ string-width@^1.0.1:
is-fullwidth-code-point "^1.0.0"
strip-ansi "^3.0.0"
"string-width@^1.0.2 || 2", string-width@^2.1.0:
"string-width@^1.0.2 || 2":
version "2.1.1"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e"
integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==
@ -1954,6 +1996,15 @@ string-width@^1.0.1:
is-fullwidth-code-point "^2.0.0"
strip-ansi "^4.0.0"
string-width@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.1.0.tgz#ba846d1daa97c3c596155308063e075ed1c99aff"
integrity sha512-NrX+1dVVh+6Y9dnQ19pR0pP4FiEIlUvdTGn8pw6CKTNq5sgib2nIhmUNT5TAmhWmvKr3WcxBcP3E8nWezuipuQ==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^5.2.0"
string_decoder@^1.1.1:
version "1.2.0"
resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.2.0.tgz#fe86e738b19544afe70469243b2a1ee9240eae8d"
@ -1982,12 +2033,12 @@ strip-ansi@^4.0.0:
dependencies:
ansi-regex "^3.0.0"
strip-ansi@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.0.0.tgz#f78f68b5d0866c20b2c9b8c61b5298508dc8756f"
integrity sha512-Uu7gQyZI7J7gn5qLn1Np3G9vcYGTVqB+lFTytnDJv83dd8T22aGH451P3jueT2/QemInJDfxHB5Tde5OzgG1Ow==
strip-ansi@^5.1.0, strip-ansi@^5.2.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae"
integrity sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==
dependencies:
ansi-regex "^4.0.0"
ansi-regex "^4.1.0"
strip-eof@^1.0.0:
version "1.0.0"
@ -2026,6 +2077,11 @@ temptmp@^1.1.0:
dependencies:
del "^3.0.0"
thirty-two@1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/thirty-two/-/thirty-two-1.0.2.tgz#4ca2fffc02a51290d2744b9e3f557693ca6b627a"
integrity sha1-TKL//AKlEpDSdEueP1V2k8prYno=
through2@^2.0.3:
version "2.0.5"
resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.5.tgz#01c1e39eb31d07cb7d03a96a70823260b23132cd"
@ -2108,6 +2164,11 @@ tweetnacl@^0.14.3, tweetnacl@~0.14.0:
resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"
integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=
type-fest@^0.5.2:
version "0.5.2"
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.5.2.tgz#d6ef42a0356c6cd45f49485c3b6281fc148e48a2"
integrity sha512-DWkS49EQKVX//Tbupb9TFa19c7+MK1XmzkrZUR8TAktmE/DizXoaoJV6TZ/tSIPXipqNiRI6CyAe7x69Jb6RSw==
union-value@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.0.tgz#5c71c34cb5bad5dcebe3ea0cd08207ba5aa1aea4"
@ -2151,16 +2212,21 @@ util-deprecate@^1.0.1, util-deprecate@~1.0.1:
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=
uuid-parse@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/uuid-parse/-/uuid-parse-1.0.0.tgz#f4657717624b0e4b88af36f98d89589a5bbee569"
integrity sha1-9GV3F2JLDkuIrzb5jYlYmlu+5Wk=
uuid-parse@1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/uuid-parse/-/uuid-parse-1.1.0.tgz#7061c5a1384ae0e1f943c538094597e1b5f3a65b"
integrity sha512-OdmXxA8rDsQ7YpNVbKSJkNzTw2I+S5WsbMDnCtIWSQaosNAcWtFuI/YK1TjzUI6nbkgiqEyh8gWngfcv8Asd9A==
uuid@^3.2.1, uuid@^3.3.2:
uuid@^3.3.2:
version "3.3.2"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131"
integrity sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==
uuid@^3.3.3:
version "3.3.3"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.3.tgz#4568f0216e78760ee1dbf3a4d2cf53e224112866"
integrity sha512-pW0No1RGHgzlpHJO1nsVrHKpOEIxkGg1xB+v0ZmdNH5OAeAwzAVrCnI2/6Mtx+Uys6iaylxa+D3g4j63IKKjSQ==
verror@1.10.0:
version "1.10.0"
resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400"
@ -2177,14 +2243,6 @@ walker@~1.0.5:
dependencies:
makeerror "1.0.x"
watch@~0.18.0:
version "0.18.0"
resolved "https://registry.yarnpkg.com/watch/-/watch-0.18.0.tgz#28095476c6df7c90c963138990c0a5423eb4b986"
integrity sha1-KAlUdsbffJDJYxOJkMClQj60uYY=
dependencies:
exec-sh "^0.2.0"
minimist "^1.2.0"
which@^1.2.9:
version "1.3.1"
resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a"
@ -2204,24 +2262,24 @@ wrappy@1:
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=
ws@^6.1.3:
version "6.1.3"
resolved "https://registry.yarnpkg.com/ws/-/ws-6.1.3.tgz#d2d2e5f0e3c700ef2de89080ebc0ac6e1bf3a72d"
integrity sha512-tbSxiT+qJI223AP4iLfQbkbxkwdFcneYinM2+x46Gx2wgvbaOMO36czfdfVUBRTHvzAMRhDd98sA5d/BuWbQdg==
ws@^7.2.0:
version "7.2.0"
resolved "https://registry.yarnpkg.com/ws/-/ws-7.2.0.tgz#422eda8c02a4b5dba7744ba66eebbd84bcef0ec7"
integrity sha512-+SqNqFbwTm/0DC18KYzIsMTnEWpLwJsiasW/O17la4iDRRIO9uaHbvKiAS3AHgTiuuWerK/brj4O6MYZkei9xg==
dependencies:
async-limiter "~1.0.0"
async-limiter "^1.0.0"
xtend@~4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af"
integrity sha1-pcbVMr5lbiPbgg77lDofBJmNY68=
xxhash@^0.2.4:
version "0.2.4"
resolved "https://registry.yarnpkg.com/xxhash/-/xxhash-0.2.4.tgz#8b8a48162cfccc21b920fa500261187d40216c39"
integrity sha1-i4pIFiz8zCG5IPpQAmEYfUAhbDk=
xxhash@^0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/xxhash/-/xxhash-0.3.0.tgz#d20893a62c5b0f7260597dd55859b12a1e02c559"
integrity sha512-1ud2yyPiR1DJhgyF1ZVMt+Ijrn0VNS/wzej1Z8eSFfkNfRPp8abVZNV2u9tYy9574II0ZayZYZgJm8KJoyGLCw==
dependencies:
nan "^2.4.0"
nan "^2.13.2"
yallist@^3.0.0, yallist@^3.0.2:
version "3.0.2"