diff --git a/.eslintrc.json b/.eslintrc.json
index 612da123..53bd1287 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -3,7 +3,9 @@
"es6": true,
"node": true
},
- "extends": "eslint:recommended",
+ "extends": [
+ "eslint:recommended"
+ ],
"rules": {
"indent": [
"error",
diff --git a/.gitignore b/.gitignore
index 28a19883..b55cbcfa 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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/
-`
\ No newline at end of file
+.vscode/
diff --git a/README.md b/README.md
index f297d060..efa593cd 100644
--- a/README.md
+++ b/README.md
@@ -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:
diff --git a/UPGRADE.md b/UPGRADE.md
index 038c5c0a..bd0a47b0 100644
--- a/UPGRADE.md
+++ b/UPGRADE.md
@@ -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.
@@ -77,7 +81,7 @@ ENiGMA 0.0.8-alpha comes with some structure changes:
With the change to the `./mods` directory, `@systemModule` is now implied for `module` declarations in `menu.hjson`. To use a user module in `./mods` you must specify `@userModule`!
With the above changes, you'll need to to at least:
-* Move your `~/.config/enigma-bbs/config.hjson` to `./config/config.hjson` or utlize the `--config` option.
+* Move your `~/.config/enigma-bbs/config.hjson` to `./config/config.hjson` or utlize the `--config` option.
* Move your `prompt.hjson` and `menu.hjson` (e.g. `myboardname.hjson`) to `./config`
* Move any non-theme art files, and theme directories to their appropriate locations mentioned above
* Move any module directories such as `message_post_evt` to `./mods/`
diff --git a/WHATSNEW.md b/WHATSNEW.md
index 39dfca49..bc8cc9f8 100644
--- a/WHATSNEW.md
+++ b/WHATSNEW.md
@@ -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
` 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.
diff --git a/art/themes/luciano_blocktronics/2FACONFSCR.ans b/art/themes/luciano_blocktronics/2FACONFSCR.ans
new file mode 100644
index 00000000..3c256000
Binary files /dev/null and b/art/themes/luciano_blocktronics/2FACONFSCR.ans differ
diff --git a/art/themes/luciano_blocktronics/2FAOTP.ans b/art/themes/luciano_blocktronics/2FAOTP.ans
new file mode 100644
index 00000000..7828fe6a
Binary files /dev/null and b/art/themes/luciano_blocktronics/2FAOTP.ans differ
diff --git a/art/themes/luciano_blocktronics/2FAOTPSECREQ.ans b/art/themes/luciano_blocktronics/2FAOTPSECREQ.ans
new file mode 100644
index 00000000..c81720ec
Binary files /dev/null and b/art/themes/luciano_blocktronics/2FAOTPSECREQ.ans differ
diff --git a/art/themes/luciano_blocktronics/MMENU.ANS b/art/themes/luciano_blocktronics/MMENU.ANS
index bd8a483f..9db39ca9 100644
Binary files a/art/themes/luciano_blocktronics/MMENU.ANS and b/art/themes/luciano_blocktronics/MMENU.ANS differ
diff --git a/art/themes/luciano_blocktronics/MSGMNU.ANS b/art/themes/luciano_blocktronics/MSGMNU.ANS
index 3585799b..01777dd9 100644
Binary files a/art/themes/luciano_blocktronics/MSGMNU.ANS and b/art/themes/luciano_blocktronics/MSGMNU.ANS differ
diff --git a/art/themes/luciano_blocktronics/MYMSGLST.ANS b/art/themes/luciano_blocktronics/MYMSGLST.ANS
new file mode 100644
index 00000000..a56197bc
Binary files /dev/null and b/art/themes/luciano_blocktronics/MYMSGLST.ANS differ
diff --git a/art/themes/luciano_blocktronics/PRVMSGLIST.ANS b/art/themes/luciano_blocktronics/PRVMSGLIST.ANS
index 180ace2b..3969d72f 100644
Binary files a/art/themes/luciano_blocktronics/PRVMSGLIST.ANS and b/art/themes/luciano_blocktronics/PRVMSGLIST.ANS differ
diff --git a/art/themes/luciano_blocktronics/USERACHIEV.ANS b/art/themes/luciano_blocktronics/USERACHIEV.ans
similarity index 100%
rename from art/themes/luciano_blocktronics/USERACHIEV.ANS
rename to art/themes/luciano_blocktronics/USERACHIEV.ans
diff --git a/art/themes/luciano_blocktronics/autosig.ans b/art/themes/luciano_blocktronics/autosig.ans
new file mode 100644
index 00000000..ce5b6d0d
Binary files /dev/null and b/art/themes/luciano_blocktronics/autosig.ans differ
diff --git a/art/themes/luciano_blocktronics/mrc.ans b/art/themes/luciano_blocktronics/mrc.ans
new file mode 100644
index 00000000..aba8b142
Binary files /dev/null and b/art/themes/luciano_blocktronics/mrc.ans differ
diff --git a/art/themes/luciano_blocktronics/theme.hjson b/art/themes/luciano_blocktronics/theme.hjson
index 4a35eb15..6d8490dc 100644
--- a/art/themes/luciano_blocktronics/theme.hjson
+++ b/art/themes/luciano_blocktronics/theme.hjson
@@ -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
+ }
+ }
}
}
}
diff --git a/core/abracadabra.js b/core/abracadabra.js
index 34374049..83aa376b 100644
--- a/core/abracadabra.js
+++ b/core/abracadabra.js
@@ -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() {
diff --git a/core/acs.js b/core/acs.js
index 1ae4fa93..f1596d5d 100644
--- a/core/acs.js
+++ b/core/acs.js
@@ -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;
diff --git a/core/acs_parser.js b/core/acs_parser.js
index d4084b95..f903d6f1 100644
--- a/core/acs_parser.js
+++ b/core/acs_parser.js
@@ -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;
diff --git a/core/art.js b/core/art.js
index d709d366..38594985 100644
--- a/core/art.js
+++ b/core/art.js
@@ -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);
}
diff --git a/core/autosig_edit.js b/core/autosig_edit.js
new file mode 100644
index 00000000..c9995280
--- /dev/null
+++ b/core/autosig_edit.js
@@ -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);
+ });
+ }
+};
diff --git a/core/bbs.js b/core/bbs.js
index 4d371fb3..98ba1b7a 100644
--- a/core/bbs.js
+++ b/core/bbs.js
@@ -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) => {
diff --git a/core/bbs_link.js b/core/bbs_link.js
index 01eb3bfe..093f0bb2 100644
--- a/core/bbs_link.js
+++ b/core/bbs_link.js
@@ -135,27 +135,34 @@ 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');
clientTerminated = true;
- bridgeConnection.end();
+ bridgeConnection.end();
});
});
- 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);
});
}
],
diff --git a/core/client.js b/core/client.js
index d340981b..566aec5d 100644
--- a/core/client.js
+++ b/core/client.js
@@ -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 () {
diff --git a/core/client_connections.js b/core/client_connections.js
index 21aa5c1c..e2c8d577 100644
--- a/core/client_connections.js
+++ b/core/client_connections.js
@@ -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;
diff --git a/core/config.js b/core/config.js
index 5c1f9c42..44b5f4cc 100644
--- a/core/config.js
+++ b/core/config.js
@@ -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/
@@ -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
diff --git a/core/connect.js b/core/connect.js
index 5d45eaa4..52ff67a0 100644
--- a/core/connect.js
+++ b/core/connect.js
@@ -112,7 +112,6 @@ function ansiAttemptDetectUTF8(client, cb) {
}
}
return cb(null);
-
}
};
diff --git a/core/database.js b/core/database.js
index 91f56a04..e0e58168 100644
--- a/core/database.js
+++ b/core/database.js
@@ -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);
},
diff --git a/core/door.js b/core/door.js
index b07c89bc..c8dd3796 100644
--- a/core/door.js
+++ b/core/door.js
@@ -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;
}
}
diff --git a/core/email.js b/core/email.js
index 1de3b034..4a41106a 100644
--- a/core/email.js
+++ b/core/email.js
@@ -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;
diff --git a/core/enig_error.js b/core/enig_error.js
index 33771564..08a3312e 100644
--- a/core/enig_error.js
+++ b/core/enig_error.js
@@ -51,4 +51,5 @@ exports.ErrorReasons = {
Inactive : 'INACTIVE',
Locked : 'LOCKED',
NotAllowed : 'NOTALLOWED',
+ Invalid2FA : 'INVALID2FA',
};
diff --git a/core/file_area_web.js b/core/file_area_web.js
index b36c8b72..b6a3d8ae 100644
--- a/core/file_area_web.js
+++ b/core/file_area_web.js
@@ -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');
diff --git a/core/file_base_area.js b/core/file_base_area.js
index ec36c7ec..e3887b84 100644
--- a/core/file_base_area.js
+++ b/core/file_base_area.js
@@ -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) {
diff --git a/core/file_transfer.js b/core/file_transfer.js
index 83dedc13..5f0138e2 100644
--- a/core/file_transfer.js
+++ b/core/file_transfer.js
@@ -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
diff --git a/core/file_util.js b/core/file_util.js
index fdea4e45..56d8d5ac 100644
--- a/core/file_util.js
+++ b/core/file_util.js
@@ -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
diff --git a/core/fse.js b/core/fse.js
index 1d9d6dd7..5e018df7 100644
--- a/core/fse.js
+++ b/core/fse.js
@@ -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);
diff --git a/core/listening_server.js b/core/listening_server.js
index aa573fa1..7cb7405e 100644
--- a/core/listening_server.js
+++ b/core/listening_server.js
@@ -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 {
diff --git a/core/login_server_module.js b/core/login_server_module.js
index 041f317c..a08abfe9 100644
--- a/core/login_server_module.js
+++ b/core/login_server_module.js
@@ -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 => {
diff --git a/core/menu_module.js b/core/menu_module.js
index 06c0f6d7..804065a7 100644
--- a/core/menu_module.js
+++ b/core/menu_module.js
@@ -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 || {};
diff --git a/core/menu_stack.js b/core/menu_stack.js
index 080d0efa..42cd6987 100644
--- a/core/menu_stack.js
+++ b/core/menu_stack.js
@@ -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) :
diff --git a/core/menu_util.js b/core/menu_util.js
index c05f90d9..a429415b 100644
--- a/core/menu_util.js
+++ b/core/menu_util.js
@@ -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!
diff --git a/core/message.js b/core/message.js
index 7a30fb02..5291b82a 100644
--- a/core/message.js
+++ b/core/message.js
@@ -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 ';
}
@@ -326,8 +329,8 @@ module.exports = class Message {
appendWhereClause(`m.area_tag = "${Message.WellKnownAreaTags.Private}"`);
appendWhereClause(
`m.message_id IN (
- SELECT message_id
- FROM message_meta
+ SELECT message_id
+ FROM message_meta
WHERE meta_category = "System" AND meta_name = "${Message.SystemMetaNames.LocalToUserID}" AND meta_value = ${filter.privateTagUserId}
)`);
} else {
@@ -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)) {
@@ -591,7 +619,7 @@ module.exports = class Message {
}
const metaStmt = transOrDb.prepare(
- `INSERT INTO message_meta (message_id, meta_category, meta_name, meta_value)
+ `INSERT INTO message_meta (message_id, meta_category, meta_name, meta_value)
VALUES (?, ?, ?, ?);`);
if(!_.isArray(value)) {
@@ -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);
diff --git a/core/message_area.js b/core/message_area.js
index 7f4993ec..579f1c9b 100644
--- a/core/message_area.js
+++ b/core/message_area.js
@@ -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);
diff --git a/core/message_base_search.js b/core/message_base_search.js
index 9684b8f0..859d2320 100644
--- a/core/message_base_search.js
+++ b/core/message_base_search.js
@@ -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 = {
diff --git a/core/module_util.js b/core/module_util.js
index f61929d2..033d6094 100644
--- a/core/module_util.js
+++ b/core/module_util.js
@@ -117,6 +117,7 @@ function getModulePaths() {
config.paths.mods,
config.paths.loginServers,
config.paths.contentServers,
+ config.paths.chatServers,
config.paths.scannerTossers,
];
}
diff --git a/core/mrc.js b/core/mrc.js
new file mode 100644
index 00000000..754e2728
--- /dev/null
+++ b/core/mrc.js
@@ -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 |08- |07List all or join a room
+|03/|11pm |03 |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 |08- |07Set the room topic
+|03/|11bbses |08& |03/|11info |08- |07Info about BBS's connected
+|03/|11meetups |08- |07Info about MRC MeetUps
+---
+|03/|11l33t |03 |08- |07l337 5p34k
+|03/|11kewl |03 |08- |07BBS KeWL SPeaK
+|03/|11rainbow |03 |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();
+ }
+};
+
+
+
+
diff --git a/core/msg_area_post_fse.js b/core/msg_area_post_fse.js
index 613cee04..f47717b2 100644
--- a/core/msg_area_post_fse.js
+++ b/core/msg_area_post_fse.js
@@ -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();
+ }
};
\ No newline at end of file
diff --git a/core/msg_list.js b/core/msg_list.js
index f73dae8a..380fc5e4 100644
--- a/core/msg_list.js
+++ b/core/msg_list.js
@@ -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;
diff --git a/core/my_messages.js b/core/my_messages.js
new file mode 100644
index 00000000..ed1d3fe1
--- /dev/null
+++ b/core/my_messages.js
@@ -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
+ );
+ }
+};
diff --git a/core/oputil/oputil_file_base.js b/core/oputil/oputil_file_base.js
index 4dc25fd2..67962fc0 100644
--- a/core/oputil/oputil_file_base.js
+++ b/core/oputil/oputil_file_base.js
@@ -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));
diff --git a/core/oputil/oputil_help.js b/core/oputil/oputil_help.js
index f8a0d42c..abcf61fe 100644
--- a/core/oputil/oputil_help.js
+++ b/core/oputil/oputil_help.js
@@ -9,113 +9,174 @@ exports.getHelpFor = getHelpFor;
const usageHelp = exports.USAGE_HELP = {
General :
`usage: oputil.js [--version] [--help]
- []
+ []
-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 []
+`usage: oputil.js user []
-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 []
+`usage: oputil.js config []
-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 []
+`usage: oputil.js fb []
-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.
-
- 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
+ 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.
`,
MessageBase :
-`usage: oputil.js mb []
+`usage: oputil.js mb []
-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".
`
};
diff --git a/core/oputil/oputil_user.js b/core/oputil/oputil_user.js
index a52facfb..78ea08ca 100644
--- a/core/oputil/oputil_user.js
+++ b/core/oputil/oputil_user.js
@@ -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);
});
}
\ No newline at end of file
diff --git a/core/servers/chat/mrc_multiplexer.js b/core/servers/chat/mrc_multiplexer.js
new file mode 100644
index 00000000..67ca9dc6
--- /dev/null
+++ b/core/servers/chat/mrc_multiplexer.js
@@ -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, '');
+}
+
diff --git a/core/servers/content/gopher.js b/core/servers/content/gopher.js
index ee889b8c..4e51c889 100644
--- a/core/servers/content/gopher.js
+++ b/core/servers/content/gopher.js
@@ -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() {
diff --git a/core/servers/content/nntp.js b/core/servers/content/nntp.js
index 4959dc64..237c0994 100644
--- a/core/servers/content/nntp.js
+++ b/core/servers/content/nntp.js
@@ -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) {
diff --git a/core/servers/content/web.js b/core/servers/content/web.js
index 1a09aace..04b9ccdd 100644
--- a/core/servers/content/web.js
+++ b/core/servers/content/web.js
@@ -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 {
diff --git a/core/servers/login/ssh.js b/core/servers/login/ssh.js
index ee63ac78..ddb1ebfd 100644
--- a/core/servers/login/ssh.js
+++ b/core/servers/login/ssh.js
@@ -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' );
}
diff --git a/core/servers/login/telnet.js b/core/servers/login/telnet.js
index fb6ca745..1b892e71 100644
--- a/core/servers/login/telnet.js
+++ b/core/servers/login/telnet.js
@@ -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' );
}
diff --git a/core/servers/login/websocket.js b/core/servers/login/websocket.js
index 35bc0757..dadcdbe6 100644
--- a/core/servers/login/websocket.js
+++ b/core/servers/login/websocket.js
@@ -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);
}
diff --git a/core/show_art.js b/core/show_art.js
index a480fa05..7e53ca60 100644
--- a/core/show_art.js
+++ b/core/show_art.js
@@ -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);
diff --git a/core/string_util.js b/core/string_util.js
index 4f7741ae..fa9a9097 100644
--- a/core/string_util.js
+++ b/core/string_util.js
@@ -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',
diff --git a/core/system_menu_method.js b/core/system_menu_method.js
index ea2cbc09..ba9bb699 100644
--- a/core/system_menu_method.js
+++ b/core/system_menu_method.js
@@ -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!
diff --git a/core/theme.js b/core/theme.js
index 9978fde3..a2d31df1 100644
--- a/core/theme.js
+++ b/core/theme.js
@@ -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.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) => {
diff --git a/core/toggle_menu_view.js b/core/toggle_menu_view.js
index aadfe9c3..c06297b2 100644
--- a/core/toggle_menu_view.js
+++ b/core/toggle_menu_view.js
@@ -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();
}
}
diff --git a/core/upload.js b/core/upload.js
index 6eaff2ab..b451ac9a 100644
--- a/core/upload.js
+++ b/core/upload.js
@@ -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 } );
diff --git a/core/user.js b/core/user.js
index 3b261dc6..2d4883d7 100644
--- a/core/user.js
+++ b/core/user.js
@@ -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);
}
diff --git a/core/user_2fa_otp.js b/core/user_2fa_otp.js
new file mode 100644
index 00000000..c51d1282
--- /dev/null
+++ b/core/user_2fa_otp.js
@@ -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();
+ });
+}
diff --git a/core/user_2fa_otp_config.js b/core/user_2fa_otp_config.js
new file mode 100644
index 00000000..1a7bf0c6
--- /dev/null
+++ b/core/user_2fa_otp_config.js
@@ -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) } );
+ }
+};
+
diff --git a/core/user_2fa_otp_web_register.js b/core/user_2fa_otp_web_register.js
new file mode 100644
index 00000000..2b7d294c
--- /dev/null
+++ b/core/user_2fa_otp_web_register.js
@@ -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);
+ }
+};
diff --git a/core/user_config.js b/core/user_config.js
index d2748c4b..ca3b20ff 100644
--- a/core/user_config.js
+++ b/core/user_config.js
@@ -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!
}
diff --git a/core/user_login.js b/core/user_login.js
index 3e3f5f04..99faa1a2 100644
--- a/core/user_login.js
+++ b/core/user_login.js
@@ -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;
}
\ No newline at end of file
diff --git a/core/user_property.js b/core/user_property.js
index 56e47e66..cc68ef09 100644
--- a/core/user_property.js
+++ b/core/user_property.js
@@ -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
};
diff --git a/core/user_temp_token.js b/core/user_temp_token.js
new file mode 100644
index 00000000..89c060d6
--- /dev/null
+++ b/core/user_temp_token.js
@@ -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);
+ }
+ );
+}
diff --git a/core/view.js b/core/view.js
index 7d3c5693..fdf78916 100644
--- a/core/view.js
+++ b/core/view.js
@@ -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;
diff --git a/core/view_controller.js b/core/view_controller.js
index 84f33756..f51c307f 100644
--- a/core/view_controller.js
+++ b/core/view_controller.js
@@ -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;
diff --git a/core/web_password_reset.js b/core/web_password_reset.js
index 90c5f57c..89c3fd33 100644
--- a/core/web_password_reset.js
+++ b/core/web_password_reset.js
@@ -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);
diff --git a/docs/_includes/nav.md b/docs/_includes/nav.md
index 8a2c29b7..e51a8734 100644
--- a/docs/_includes/nav.md
+++ b/docs/_includes/nav.md
@@ -3,13 +3,13 @@
- [Install script]({{ site.baseurl }}{% link installation/install-script.md %})
- [Docker]({{ site.baseurl }}{% link installation/docker.md %})
- [Manual installation]({{ site.baseurl }}{% link installation/manual.md %})
- - [OS / Hardware Specific]({{ site.baseurl }}{% link installation/os-hardware.md %})
- - [Raspberry Pi]({{ site.baseurl }}{% link installation/rpi.md %})
- - [Windows]({{ site.baseurl }}{% link installation/windows.md %})
+ - [OS / Hardware Specific]({{ site.baseurl }}{% link installation/os-hardware.md %})
+ - [Raspberry Pi]({{ site.baseurl }}{% link installation/rpi.md %})
+ - [Windows]({{ site.baseurl }}{% link installation/windows.md %})
- [Your Network Setup]({{ site.baseurl }}{% link installation/network.md %})
- [Testing Your Installation]({{ site.baseurl }}{% link installation/testing.md %})
- [Production Installation]({{ site.baseurl }}{% link installation/production.md %})
-
+
- Configuration
- [Creating Config Files]({{ site.baseurl }}{% link configuration/creating-config.md %})
- [SysOp Setup]({{ site.baseurl }}{% link configuration/sysop-setup.md %})
@@ -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 %})
@@ -34,13 +35,13 @@
- [TIC Support]({{ site.baseurl }}{% link filebase/tic-support.md %}) (Importing from FTN networks)
- Tips and tricks
- [Network mounts and symlinks]({{ site.baseurl }}{% link filebase/network-mounts-and-symlinks.md %})
-
+
- Message Areas
- [Configuring a Message Area]({{ site.baseurl }}{% link messageareas/configuring-a-message-area.md %})
- [Message networks]({{ site.baseurl }}{% link messageareas/message-networks.md %})
- [BSO Import & Export]({{ site.baseurl }}{% link messageareas/bso-import-export.md %})
- - [Netmail]({{ site.baseurl }}{% link messageareas/netmail.md %})
-
+ - [Netmail]({{ site.baseurl }}{% link messageareas/netmail.md %})
+
- Art
- [General]({{ site.baseurl }}{% link art/general.md %})
- [Themes]({{ site.baseurl }}{% link art/themes.md %})
@@ -56,10 +57,10 @@
- [Web]({{ site.baseurl }}{% link servers/web-server.md %})
- [Gopher]({{ site.baseurl }}{% link servers/gopher.md %})
- [NNTP]({{ site.baseurl }}{% link servers/nntp.md %})
-
+
- Modding
- [Local Doors]({{ site.baseurl }}{% link modding/local-doors.md %})
- - [Door Servers]({{ site.baseurl }}{% link modding/door-servers.md %})
+ - [Door Servers]({{ site.baseurl }}{% link modding/door-servers.md %})
- DoorParty
- BBSLink
- Combatnet
@@ -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 %})
diff --git a/docs/admin/oputil.md b/docs/admin/oputil.md
index bb6dcacf..0b05f7aa 100644
--- a/docs/admin/oputil.md
+++ b/docs/admin/oputil.md
@@ -9,17 +9,18 @@ Let's look the main help output as per this writing:
```
usage: oputil.js [--version] [--help]
- []
+ []
-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 --help` for additional help on a particular command.
The `user` command covers various user operations.
```
-usage: oputil.js user []
+usage: oputil.js user []
-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`
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 []
+usage: oputil.js config []
-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 []
+usage: oputil.js fb []
-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.
-
- 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
+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
+
+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 []
+usage: oputil.js mb []
-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 |
diff --git a/docs/art/mci.md b/docs/art/mci.md
index 264d51b0..60bb8ae6 100644
--- a/docs/art/mci.md
+++ b/docs/art/mci.md
@@ -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 |
diff --git a/docs/configuration/acs.md b/docs/configuration/acs.md
index d0a45d06..dd57ce22 100644
--- a/docs/configuration/acs.md
+++ b/docs/configuration/acs.md
@@ -37,8 +37,8 @@ The following are ACS codes available as of this writing:
| MMminutes | It is currently >= _minutes_ past midnight (system time) |
| ACachievementCount | User has >= _achievementCount_ achievements |
| APachievementPoints | User has >= _achievementPoints_ achievement points |
-
-\* Many more ACS codes are planned for the near future.
+| AFauthFactor | 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. |
+| ARauthFactorReq | Current user **requires** an Authentication Factor >= _authFactorReq_ |
## ACS Strings
ACS strings are one or more ACS codes in addition to some basic language semantics.
diff --git a/docs/configuration/archivers.md b/docs/configuration/archivers.md
index fb9cb7c1..3f5d941e 100644
--- a/docs/configuration/archivers.md
+++ b/docs/configuration/archivers.md
@@ -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: LHA files such as .lzh.
diff --git a/docs/configuration/menu-hjson.md b/docs/configuration/menu-hjson.md
index a59e5b24..8ddda2d8 100644
--- a/docs/configuration/menu-hjson.md
+++ b/docs/configuration/menu-hjson.md
@@ -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:
```
diff --git a/docs/configuration/security.md b/docs/configuration/security.md
new file mode 100644
index 00000000..afc583aa
--- /dev/null
+++ b/docs/configuration/security.md
@@ -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
+ }
+ ]
+ // ...
+}
+```
\ No newline at end of file
diff --git a/docs/installation/install-script.md b/docs/installation/install-script.md
index 584492a2..c24dede9 100644
--- a/docs/installation/install-script.md
+++ b/docs/installation/install-script.md
@@ -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.
diff --git a/docs/messageareas/configuring-a-message-area.md b/docs/messageareas/configuring-a-message-area.md
index 63af2826..011e9497 100644
--- a/docs/messageareas/configuring-a-message-area.md
+++ b/docs/messageareas/configuring-a-message-area.md
@@ -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!
- name: ENiGMA 1/2 Development
- desc: ENiGMA 1/2 discussion!
- sort: 1
+ enigma_dev: { // Area tag - required elsewhere!
+ name: ENiGMA 1/2 Development
+ 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!
}
}
}
diff --git a/docs/modding/autosig-edit.md b/docs/modding/autosig-edit.md
new file mode 100644
index 00000000..f0da9b18
--- /dev/null
+++ b/docs/modding/autosig-edit.md
@@ -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
+}
+```
diff --git a/docs/modding/top-x.md b/docs/modding/top-x.md
index 50d69bee..41ac5ab7 100644
--- a/docs/modding/top-x.md
+++ b/docs/modding/top-x.md
@@ -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.
diff --git a/docs/modding/user-2fa-otp-config.md b/docs/modding/user-2fa-otp-config.md
new file mode 100644
index 00000000..7b41939f
--- /dev/null
+++ b/docs/modding/user-2fa-otp-config.md
@@ -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.
diff --git a/misc/acs_parser.pegjs b/misc/acs_parser.pegjs
index 8a39deea..25e5853a 100644
--- a/misc/acs_parser.pegjs
+++ b/misc/acs_parser.pegjs
@@ -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;
diff --git a/misc/config_template.in.hjson b/misc/config_template.in.hjson
index 5e523e72..abcdc09a 100644
--- a/misc/config_template.in.hjson
+++ b/misc/config_template.in.hjson
@@ -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
diff --git a/misc/install.sh b/misc/install.sh
index 1203705c..5da8e0e4 100755
--- a/misc/install.sh
+++ b/misc/install.sh
@@ -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() {
diff --git a/misc/menu_template.in.hjson b/misc/menu_template.in.hjson
index bdcf97cd..57a3f761 100644
--- a/misc/menu_template.in.hjson
+++ b/misc/menu_template.in.hjson
@@ -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
diff --git a/misc/otp_register_email.template.html b/misc/otp_register_email.template.html
new file mode 100644
index 00000000..f9fcd65b
--- /dev/null
+++ b/misc/otp_register_email.template.html
@@ -0,0 +1,11 @@
+%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 this link or copy and paste the link below:
+ %REGISTER_URL%
+
+
+
diff --git a/misc/otp_register_email.template.txt b/misc/otp_register_email.template.txt
new file mode 100644
index 00000000..c275515e
--- /dev/null
+++ b/misc/otp_register_email.template.txt
@@ -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%
diff --git a/package.json b/package.json
index 15add31e..8fde7f20 100644
--- a/package.json
+++ b/package.json
@@ -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 ",
"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": {},
diff --git a/www/otp_register.template.html b/www/otp_register.template.html
new file mode 100644
index 00000000..20a63ed2
--- /dev/null
+++ b/www/otp_register.template.html
@@ -0,0 +1,29 @@
+
+
+
+
+ Enable 2FA/OTP — ENiGMA½ BBS
+
+
+
+
+
+ Your OTP secret:
+ %SECRET%
+
+
+
+
+
\ No newline at end of file
diff --git a/yarn.lock b/yarn.lock
index 8a18bc57..067bb3c5 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"