Sync up with 0.0.10-alpha
This commit is contained in:
commit
0bfeca090d
|
@ -3,7 +3,9 @@
|
|||
"es6": true,
|
||||
"node": true
|
||||
},
|
||||
"extends": "eslint:recommended",
|
||||
"extends": [
|
||||
"eslint:recommended"
|
||||
],
|
||||
"rules": {
|
||||
"indent": [
|
||||
"error",
|
||||
|
|
|
@ -2,11 +2,13 @@
|
|||
*.pem
|
||||
|
||||
# Various directories
|
||||
config/config.hjson
|
||||
logs/
|
||||
config/
|
||||
db/
|
||||
dropfiles/
|
||||
drop/
|
||||
file_base/
|
||||
logs/
|
||||
mail/
|
||||
node_modules/
|
||||
docs/_site/
|
||||
docs/.sass-cache/
|
||||
`
|
||||
.vscode/
|
||||
|
|
14
README.md
14
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:
|
||||
|
|
|
@ -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.
|
||||
|
|
53
WHATSNEW.md
53
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 <address>` format from your personal email menu. For example, `Foo Bar <123:123/123>`. The system also detects other formats such asa `Name @ address` (`Foo Bar@123:123/123`)
|
||||
* `oputil.js`: Added `mb areafix` command to quickly send AreaFix messages from the command line. You can manually send them from personal mail as well.
|
||||
* `oputil.js fb rm|remove|del|delete` functionality to remove file base entries.
|
||||
|
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
32
core/acs.js
32
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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
};
|
|
@ -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) => {
|
||||
|
|
|
@ -135,16 +135,21 @@ exports.getModule = class BBSLinkModule extends MenuModule {
|
|||
};
|
||||
|
||||
let clientTerminated;
|
||||
let dataOut;
|
||||
|
||||
self.client.term.write(resetScreen());
|
||||
self.client.term.write(' Connecting to BBSLink.net, please wait...\n');
|
||||
self.client.term.write(` Connecting to ${self.config.host}, please wait...\n`);
|
||||
|
||||
const doorTracking = trackDoorRunBegin(self.client, `bbslink_${self.config.door}`);
|
||||
|
||||
const bridgeConnection = net.createConnection(connectOpts, function connected() {
|
||||
self.client.log.info(connectOpts, 'BBSLink bridge connection established');
|
||||
|
||||
self.client.term.output.pipe(bridgeConnection);
|
||||
dataOut = (data) => {
|
||||
return bridgeConnection.write(data);
|
||||
};
|
||||
|
||||
self.client.term.output.on('data', dataOut);
|
||||
|
||||
self.client.once('end', function clientEnd() {
|
||||
self.client.log.info('Connection ended. Terminating BBSLink connection');
|
||||
|
@ -153,9 +158,11 @@ exports.getModule = class BBSLinkModule extends MenuModule {
|
|||
});
|
||||
});
|
||||
|
||||
const restorePipe = function() {
|
||||
self.client.term.output.unpipe(bridgeConnection);
|
||||
self.client.term.output.resume();
|
||||
const restore = () => {
|
||||
if(dataOut && self.client.term.output) {
|
||||
self.client.term.output.removeListener('data', dataOut);
|
||||
dataOut = null;
|
||||
}
|
||||
|
||||
trackDoorRunEnd(doorTracking);
|
||||
};
|
||||
|
@ -167,14 +174,14 @@ exports.getModule = class BBSLinkModule extends MenuModule {
|
|||
});
|
||||
|
||||
bridgeConnection.on('end', function connectionEnd() {
|
||||
restorePipe();
|
||||
restore();
|
||||
return callback(clientTerminated ? Errors.General('Client connection terminated') : null);
|
||||
});
|
||||
|
||||
bridgeConnection.on('error', function error(err) {
|
||||
self.client.log.info('BBSLink bridge connection error: ' + err.message);
|
||||
restorePipe();
|
||||
callback(err);
|
||||
restore();
|
||||
return callback(err);
|
||||
});
|
||||
}
|
||||
],
|
||||
|
|
|
@ -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 () {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -169,6 +169,11 @@ function getDefaultConfig() {
|
|||
return {
|
||||
general : {
|
||||
boardName : 'Another Fine ENiGMA½ BBS',
|
||||
prettyBoardName : '|08A|07nother |07F|08ine |07E|08NiGMA|07½ B|08BS',
|
||||
telnetHostname : '',
|
||||
sshHostname : '',
|
||||
website : 'https://enigma-bbs.github.io',
|
||||
description : 'An ENiGMA½ BBS',
|
||||
|
||||
// :TODO: closedSystem prob belongs under users{}?
|
||||
closedSystem : false, // is the system closed to new users?
|
||||
|
@ -212,7 +217,8 @@ function getDefaultConfig() {
|
|||
|
||||
badUserNames : [
|
||||
'sysop', 'admin', 'administrator', 'root', 'all',
|
||||
'areamgr', 'filemgr', 'filefix', 'areafix', 'allfix'
|
||||
'areamgr', 'filemgr', 'filefix', 'areafix', 'allfix',
|
||||
'server', 'client', 'notme'
|
||||
],
|
||||
|
||||
preAuthIdleLogoutSeconds : 60 * 3, // 3m
|
||||
|
@ -224,6 +230,16 @@ function getDefaultConfig() {
|
|||
autoUnlockMinutes : 60 * 6, // 0=disabled; Auto unlock after N minutes.
|
||||
},
|
||||
unlockAtEmailPwReset : true, // if true, password reset via email will unlock locked accounts
|
||||
|
||||
twoFactorAuth : {
|
||||
method : 'googleAuth',
|
||||
|
||||
otp : {
|
||||
registerEmailText : paths.join(__dirname, '../misc/otp_register_email.template.txt'),
|
||||
registerEmailHtml : paths.join(__dirname, '../misc/otp_register_email.template.html'),
|
||||
registerPageTemplate : paths.join(__dirname, '../www/otp_register.template.html'),
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
theme : {
|
||||
|
@ -250,16 +266,18 @@ function getDefaultConfig() {
|
|||
|
||||
paths : {
|
||||
config : paths.join(__dirname, './../config/'),
|
||||
security : paths.join(__dirname, './../config/security'), // certs, keys, etc.
|
||||
mods : paths.join(__dirname, './../mods/'),
|
||||
loginServers : paths.join(__dirname, './servers/login/'),
|
||||
contentServers : paths.join(__dirname, './servers/content/'),
|
||||
chatServers : paths.join(__dirname, './servers/chat/'),
|
||||
|
||||
scannerTossers : paths.join(__dirname, './scanner_tossers/'),
|
||||
mailers : paths.join(__dirname, './mailers/') ,
|
||||
|
||||
art : paths.join(__dirname, './../art/general/'),
|
||||
themes : paths.join(__dirname, './../art/themes/'),
|
||||
logs : paths.join(__dirname, './../logs/'), // :TODO: set up based on system, e.g. /var/logs/enigmabbs or such
|
||||
logs : paths.join(__dirname, './../logs/'),
|
||||
db : paths.join(__dirname, './../db/'),
|
||||
modsDb : paths.join(__dirname, './../db/mods/'),
|
||||
dropFiles : paths.join(__dirname, './../drop/'), // + "/node<x>/
|
||||
|
@ -284,10 +302,10 @@ function getDefaultConfig() {
|
|||
//
|
||||
// > openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \
|
||||
// -pkeyopt rsa_keygen_pubexp:65537 | openssl rsa \
|
||||
// -out ./config/ssh_private_key.pem -aes128
|
||||
// -out ./config/security/ssh_private_key.pem -aes128
|
||||
//
|
||||
// (The above is a more modern equivelant of the following):
|
||||
// > openssl genrsa -aes128 -out ./config/ssh_private_key.pem 2048
|
||||
// (The above is a more modern equivalent of the following):
|
||||
// > openssl genrsa -aes128 -out ./config/security/ssh_private_key.pem 2048
|
||||
//
|
||||
// 2 - Set 'privateKeyPass' to the password you used in step #1
|
||||
//
|
||||
|
@ -297,7 +315,7 @@ function getDefaultConfig() {
|
|||
// - https://blog.sleeplessbeastie.eu/2017/12/28/how-to-generate-private-key/
|
||||
// - https://gist.github.com/briansmith/2ee42439923d8e65a266994d0f70180b
|
||||
//
|
||||
privateKeyPem : paths.join(__dirname, './../config/ssh_private_key.pem'),
|
||||
privateKeyPem : paths.join(__dirname, './../config/security/ssh_private_key.pem'),
|
||||
firstMenu : 'sshConnected',
|
||||
firstMenuNewUser : 'sshConnectedNewUser',
|
||||
|
||||
|
@ -447,6 +465,16 @@ function getDefaultConfig() {
|
|||
}
|
||||
},
|
||||
|
||||
chatServers : {
|
||||
mrc: {
|
||||
enabled : false,
|
||||
serverHostname : 'mrc.bottomlessabyss.net',
|
||||
serverPort : 5000,
|
||||
retryDelay : 10000,
|
||||
multiplexerPort : 5000,
|
||||
}
|
||||
},
|
||||
|
||||
infoExtractUtils : {
|
||||
Exiftool2Desc : {
|
||||
cmd : `${__dirname}/../util/exiftool2desc.js`, // ensure chmod +x
|
||||
|
@ -549,7 +577,7 @@ function getDefaultConfig() {
|
|||
desc : 'ZIP Archive',
|
||||
sig : '504b0304',
|
||||
offset : 0,
|
||||
archiveHandler : '7Zip',
|
||||
archiveHandler : 'InfoZip',
|
||||
},
|
||||
/*
|
||||
'application/x-cbr' : {
|
||||
|
@ -623,7 +651,7 @@ function getDefaultConfig() {
|
|||
|
||||
archives : {
|
||||
archivers : {
|
||||
'7Zip' : {
|
||||
'7Zip' : { // p7zip package
|
||||
compress : {
|
||||
cmd : '7za',
|
||||
args : [ 'a', '-tzip', '{archivePath}', '{fileList}' ],
|
||||
|
@ -643,6 +671,27 @@ function getDefaultConfig() {
|
|||
},
|
||||
},
|
||||
|
||||
InfoZip: {
|
||||
compress : {
|
||||
cmd : 'zip',
|
||||
args : [ '{archivePath}', '{fileList}' ],
|
||||
},
|
||||
decompress : {
|
||||
cmd : 'unzip',
|
||||
args : [ '{archivePath}', '-d', '{extractPath}' ],
|
||||
},
|
||||
list : {
|
||||
cmd : 'unzip',
|
||||
args : [ '-l', '{archivePath}' ],
|
||||
// Annoyingly, dates can be in YYYY-MM-DD or MM-DD-YYYY format
|
||||
entryMatch : '^\\s*([0-9]+)\\s+[0-9]{2,4}-[0-9]{2}-[0-9]{2,4}\\s+[0-9]{2}:[0-9]{2}\\s+([^\\r\\n]+)$',
|
||||
},
|
||||
extract : {
|
||||
cmd : 'unzip',
|
||||
args : [ '{archivePath}', '{fileList}', '-d', '{extractPath}' ],
|
||||
}
|
||||
},
|
||||
|
||||
Lha : {
|
||||
//
|
||||
// 'lha' command can be obtained from:
|
||||
|
@ -998,6 +1047,15 @@ function getDefaultConfig() {
|
|||
args : [ '24 hours' ] // items older than this will be removed
|
||||
},
|
||||
|
||||
twoFactorRegisterTokenMaintenance : {
|
||||
schedule : 'every 24 hours',
|
||||
action : '@method:core/user_temp_token.js:temporaryTokenMaintenanceTask',
|
||||
args : [
|
||||
'auth_factor2_otp_register',
|
||||
'24 hours', // expire time
|
||||
]
|
||||
},
|
||||
|
||||
//
|
||||
// Enable the following entry in your config.hjson to periodically create/update
|
||||
// DESCRIPT.ION files for your file base
|
||||
|
|
|
@ -112,7 +112,6 @@ function ansiAttemptDetectUTF8(client, cb) {
|
|||
}
|
||||
}
|
||||
return cb(null);
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
|
||||
|
|
38
core/door.js
38
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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -51,4 +51,5 @@ exports.ErrorReasons = {
|
|||
Inactive : 'INACTIVE',
|
||||
Locked : 'LOCKED',
|
||||
NotAllowed : 'NOTALLOWED',
|
||||
Invalid2FA : 'INVALID2FA',
|
||||
};
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
31
core/fse.js
31
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);
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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 || {};
|
||||
|
||||
|
|
|
@ -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) :
|
||||
|
|
|
@ -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!
|
||||
|
||||
|
|
|
@ -236,16 +236,18 @@ module.exports = class Message {
|
|||
|
||||
filter.uuids - use with resultType='id'
|
||||
filter.ids - use with resultType='uuid'
|
||||
filter.toUserName
|
||||
filter.fromUserName
|
||||
filter.toUserName - string|Array(string)
|
||||
filter.fromUserName - string|Array(string)
|
||||
filter.replyToMessageId
|
||||
|
||||
filter.operator = (AND)|OR
|
||||
|
||||
filter.newerThanTimestamp - may not be used with |date|
|
||||
filter.date - moment object - may not be used with |newerThanTimestamp|
|
||||
|
||||
filter.newerThanMessageId
|
||||
filter.areaTag - note if you want by conf, send in all areas for a conf
|
||||
*filter.metaTuples - {category, name, value}
|
||||
filter.metaTuples - [ {category, name, value} ]
|
||||
|
||||
filter.terms - FTS search
|
||||
|
||||
|
@ -267,6 +269,7 @@ module.exports = class Message {
|
|||
|
||||
filter.resultType = filter.resultType || 'id';
|
||||
filter.extraFields = filter.extraFields || [];
|
||||
filter.operator = filter.operator || 'AND';
|
||||
|
||||
if('messageList' === filter.resultType) {
|
||||
filter.extraFields = _.uniq(filter.extraFields.concat(
|
||||
|
@ -296,9 +299,9 @@ module.exports = class Message {
|
|||
let sqlOrderBy;
|
||||
let sqlWhere = '';
|
||||
|
||||
function appendWhereClause(clause) {
|
||||
function appendWhereClause(clause, op) {
|
||||
if(sqlWhere) {
|
||||
sqlWhere += ' AND ';
|
||||
sqlWhere += ` ${op || filter.operator} `;
|
||||
} else {
|
||||
sqlWhere += ' WHERE ';
|
||||
}
|
||||
|
@ -345,7 +348,7 @@ module.exports = class Message {
|
|||
}
|
||||
|
||||
// explicit exclude of Private
|
||||
appendWhereClause(`m.area_tag != "${Message.WellKnownAreaTags.Private}"`);
|
||||
appendWhereClause(`m.area_tag != "${Message.WellKnownAreaTags.Private}"`, 'AND');
|
||||
}
|
||||
|
||||
if(_.isNumber(filter.replyToMessageId)) {
|
||||
|
@ -353,8 +356,18 @@ module.exports = class Message {
|
|||
}
|
||||
|
||||
[ 'toUserName', 'fromUserName' ].forEach(field => {
|
||||
if(_.isString(filter[field]) && filter[field].length > 0) {
|
||||
appendWhereClause(`m.${_.snakeCase(field)} LIKE "${sanitizeString(filter[field])}"`);
|
||||
let val = filter[field];
|
||||
if(!val) {
|
||||
return; // next item
|
||||
}
|
||||
if(_.isString(val)) {
|
||||
val = [ val ];
|
||||
}
|
||||
if(Array.isArray(val)) {
|
||||
val = '(' + val.map(v => {
|
||||
return `m.${_.snakeCase(field)} LIKE "${sanitizeString(v)}"`;
|
||||
}).join(' OR ') + ')';
|
||||
appendWhereClause(val);
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -380,6 +393,21 @@ module.exports = class Message {
|
|||
);
|
||||
}
|
||||
|
||||
if(Array.isArray(filter.metaTuples)) {
|
||||
let sub = [];
|
||||
filter.metaTuples.forEach(mt => {
|
||||
sub.push(`(meta_category = "${mt.category}" AND meta_name = "${mt.name}" AND meta_value = "${sanitizeString(mt.value)}")`);
|
||||
});
|
||||
sub = sub.join(` ${filter.operator} `);
|
||||
appendWhereClause(
|
||||
`m.message_id IN (
|
||||
SELECT message_id
|
||||
FROM message_meta
|
||||
WHERE ${sub}
|
||||
)`
|
||||
);
|
||||
}
|
||||
|
||||
sql += `${sqlWhere} ${sqlOrderBy}`;
|
||||
|
||||
if(_.isNumber(filter.limit)) {
|
||||
|
@ -812,7 +840,7 @@ module.exports = class Message {
|
|||
} else {
|
||||
const QUOTE_RE = /^ ((?:[A-Za-z0-9]{2}> )+(?:[A-Za-z0-9]{2}>)*) */;
|
||||
const quoted = [];
|
||||
const input = _.trimEnd(this.message).replace(/\b/g, '');
|
||||
const input = _.trimEnd(this.message).replace(/\x08/g, '');
|
||||
|
||||
// find *last* tearline
|
||||
let tearLinePos = this.getTearLinePosition(input);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -117,6 +117,7 @@ function getModulePaths() {
|
|||
config.paths.mods,
|
||||
config.paths.loginServers,
|
||||
config.paths.contentServers,
|
||||
config.paths.chatServers,
|
||||
config.paths.scannerTossers,
|
||||
];
|
||||
}
|
||||
|
|
|
@ -0,0 +1,575 @@
|
|||
/* jslint node: true */
|
||||
'use strict';
|
||||
|
||||
// ENiGMA½
|
||||
const Log = require('./logger.js').log;
|
||||
const { MenuModule } = require('./menu_module.js');
|
||||
const {
|
||||
pipeToAnsi,
|
||||
stripMciColorCodes
|
||||
} = require('./color_codes.js');
|
||||
const stringFormat = require('./string_format.js');
|
||||
const StringUtil = require('./string_util.js');
|
||||
const Config = require('./config.js').get;
|
||||
|
||||
// deps
|
||||
const _ = require('lodash');
|
||||
const async = require('async');
|
||||
const net = require('net');
|
||||
const moment = require('moment');
|
||||
|
||||
exports.moduleInfo = {
|
||||
name : 'MRC Client',
|
||||
desc : 'Connects to an MRC chat server',
|
||||
author : 'RiPuk',
|
||||
packageName : 'codes.l33t.enigma.mrc.client',
|
||||
|
||||
// Whilst this module was put together by me (RiPuk), it should be noted that a lot of the ideas (and even some code snippets) were
|
||||
// borrowed from the Synchronet implementation of MRC by echicken. So...thanks, your code was very helpful in putting this together.
|
||||
// Source at http://cvs.synchro.net/cgi-bin/viewcvs.cgi/xtrn/mrc/.
|
||||
};
|
||||
|
||||
const FormIds = {
|
||||
mrcChat : 0,
|
||||
};
|
||||
|
||||
const MciViewIds = {
|
||||
mrcChat : {
|
||||
chatLog : 1,
|
||||
inputArea : 2,
|
||||
roomName : 3,
|
||||
roomTopic : 4,
|
||||
mrcUsers : 5,
|
||||
mrcBbses : 6,
|
||||
|
||||
customRangeStart : 20, // 20+ = customs
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
// TODO: this is a bit shit, could maybe do it with an ansi instead
|
||||
const helpText = `
|
||||
|15General Chat|08:
|
||||
|03/|11rooms |08& |03/|11join |03<room> |08- |07List all or join a room
|
||||
|03/|11pm |03<user> <message> |08- |07Send a private message
|
||||
----
|
||||
|03/|11whoon |08- |07Who's on what BBS
|
||||
|03/|11chatters |08- |07Who's in what room
|
||||
|03/|11clear |08- |07Clear back buffer
|
||||
|03/|11topic |03<message> |08- |07Set the room topic
|
||||
|03/|11bbses |08& |03/|11info <id> |08- |07Info about BBS's connected
|
||||
|03/|11meetups |08- |07Info about MRC MeetUps
|
||||
---
|
||||
|03/|11l33t |03<your message> |08- |07l337 5p34k
|
||||
|03/|11kewl |03<your message> |08- |07BBS KeWL SPeaK
|
||||
|03/|11rainbow |03<your message> |08- |07Crazy rainbow text
|
||||
`;
|
||||
|
||||
|
||||
exports.getModule = class mrcModule extends MenuModule {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
|
||||
this.log = Log.child( { module : 'MRC' } );
|
||||
this.config = Object.assign({}, _.get(options, 'menuConfig.config'), { extraArgs : options.extraArgs });
|
||||
|
||||
this.config.maxScrollbackLines = this.config.maxScrollbackLines || 500;
|
||||
|
||||
this.state = {
|
||||
socket: '',
|
||||
alias: this.client.user.username,
|
||||
room: '',
|
||||
room_topic: '',
|
||||
nicks: [],
|
||||
lastSentMsg : {}, // used for latency est.
|
||||
};
|
||||
|
||||
this.customFormatObj = {
|
||||
roomName : '',
|
||||
roomTopic : '',
|
||||
roomUserCount : 0,
|
||||
userCount : 0,
|
||||
boardCount : 0,
|
||||
roomCount : 0,
|
||||
latencyMs : 0,
|
||||
activityLevel : 0,
|
||||
activityLevelIndicator : ' ',
|
||||
};
|
||||
|
||||
this.menuMethods = {
|
||||
|
||||
sendChatMessage : (formData, extraArgs, cb) => {
|
||||
|
||||
const inputAreaView = this.viewControllers.mrcChat.getView(MciViewIds.mrcChat.inputArea);
|
||||
const inputData = inputAreaView.getData();
|
||||
|
||||
this.processOutgoingMessage(inputData);
|
||||
inputAreaView.clearText();
|
||||
|
||||
return cb(null);
|
||||
},
|
||||
|
||||
movementKeyPressed : (formData, extraArgs, cb) => {
|
||||
const bodyView = this.viewControllers.mrcChat.getView(MciViewIds.mrcChat.chatLog);
|
||||
switch(formData.key.name) {
|
||||
case 'down arrow' : bodyView.scrollDocumentUp(); break;
|
||||
case 'up arrow' : bodyView.scrollDocumentDown(); break;
|
||||
case 'page up' : bodyView.keyPressPageUp(); break;
|
||||
case 'page down' : bodyView.keyPressPageDown(); break;
|
||||
}
|
||||
|
||||
this.viewControllers.mrcChat.switchFocus(MciViewIds.mrcChat.inputArea);
|
||||
|
||||
return cb(null);
|
||||
},
|
||||
|
||||
quit : (formData, extraArgs, cb) => {
|
||||
return this.prevMenu(cb);
|
||||
},
|
||||
|
||||
clearMessages : (formData, extraArgs, cb) => {
|
||||
this.clearMessages();
|
||||
return cb(null);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
mciReady(mciData, cb) {
|
||||
super.mciReady(mciData, err => {
|
||||
if(err) {
|
||||
return cb(err);
|
||||
}
|
||||
|
||||
async.series(
|
||||
[
|
||||
(callback) => {
|
||||
return this.prepViewController('mrcChat', FormIds.mrcChat, mciData.menu, callback);
|
||||
},
|
||||
(callback) => {
|
||||
return this.validateMCIByViewIds('mrcChat', [ MciViewIds.mrcChat.chatLog, MciViewIds.mrcChat.inputArea ], callback);
|
||||
},
|
||||
(callback) => {
|
||||
const connectOpts = {
|
||||
port : _.get(Config(), 'chatServers.mrc.multiplexerPort', 5000),
|
||||
host : 'localhost',
|
||||
};
|
||||
|
||||
// connect to multiplexer
|
||||
this.state.socket = net.createConnection(connectOpts, () => {
|
||||
this.client.once('end', () => {
|
||||
this.quitServer();
|
||||
});
|
||||
|
||||
// handshake with multiplexer
|
||||
this.state.socket.write(`--DUDE-ITS--|${this.state.alias}\n`);
|
||||
|
||||
this.clientConnect();
|
||||
|
||||
// send register to central MRC and get stats every 60s
|
||||
this.heartbeat = setInterval( () => {
|
||||
this.sendHeartbeat();
|
||||
this.sendServerMessage('STATS');
|
||||
}, 60000);
|
||||
|
||||
// override idle logout seconds if configured
|
||||
const idleLogoutSeconds = parseInt(this.config.idleLogoutSeconds);
|
||||
if(0 === idleLogoutSeconds) {
|
||||
this.log.debug('Temporary disable idle monitor due to config');
|
||||
this.client.stopIdleMonitor();
|
||||
} else if (!isNaN(idleLogoutSeconds) && idleLogoutSeconds >= 60) {
|
||||
this.log.debug( { idleLogoutSeconds }, 'Temporary override idle logout seconds due to config');
|
||||
this.client.overrideIdleLogoutSeconds(idleLogoutSeconds);
|
||||
}
|
||||
});
|
||||
|
||||
// when we get data, process it
|
||||
this.state.socket.on('data', data => {
|
||||
data = data.toString();
|
||||
this.processReceivedMessage(data);
|
||||
});
|
||||
|
||||
this.state.socket.once('error', err => {
|
||||
this.log.warn( { error : err.message }, 'MRC multiplexer socket error' );
|
||||
this.state.socket.destroy();
|
||||
delete this.state.socket;
|
||||
|
||||
// bail with error - fall back to prev menu
|
||||
return callback(err);
|
||||
});
|
||||
|
||||
return(callback);
|
||||
}
|
||||
],
|
||||
err => {
|
||||
return cb(err);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
leave() {
|
||||
this.quitServer();
|
||||
|
||||
// restore idle monitor to previous state
|
||||
this.log.debug('Restoring idle monitor to previous state');
|
||||
this.client.restoreIdleLogoutSeconds();
|
||||
this.client.startIdleMonitor();
|
||||
|
||||
return super.leave();
|
||||
}
|
||||
|
||||
quitServer() {
|
||||
clearInterval(this.heartbeat);
|
||||
|
||||
if(this.state.socket) {
|
||||
this.sendServerMessage('LOGOFF');
|
||||
this.state.socket.destroy();
|
||||
delete this.state.socket;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a message to the chat log on screen
|
||||
*/
|
||||
addMessageToChatLog(message) {
|
||||
if(!Array.isArray(message)) {
|
||||
message = [ message ];
|
||||
}
|
||||
|
||||
message.forEach(msg => {
|
||||
const chatLogView = this.viewControllers.mrcChat.getView(MciViewIds.mrcChat.chatLog);
|
||||
const messageLength = stripMciColorCodes(msg).length;
|
||||
const chatWidth = chatLogView.dimens.width;
|
||||
let padAmount = 0;
|
||||
let spaces = 2;
|
||||
|
||||
if (messageLength > chatWidth) {
|
||||
padAmount = chatWidth - (messageLength % chatWidth) - spaces;
|
||||
} else {
|
||||
padAmount = chatWidth - messageLength - spaces;
|
||||
}
|
||||
|
||||
if (padAmount < 0) padAmount = 0;
|
||||
|
||||
const padding = ' |00' + ' '.repeat(padAmount);
|
||||
chatLogView.addText(pipeToAnsi(msg + padding));
|
||||
|
||||
if(chatLogView.getLineCount() > this.config.maxScrollbackLines) {
|
||||
chatLogView.deleteLine(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes data received from the MRC multiplexer
|
||||
*/
|
||||
processReceivedMessage(blob) {
|
||||
blob.split('\n').forEach( message => {
|
||||
|
||||
try {
|
||||
message = JSON.parse(message);
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.from_user == 'SERVER') {
|
||||
const params = message.body.split(':');
|
||||
|
||||
switch (params[0]) {
|
||||
case 'BANNER':
|
||||
this.addMessageToChatLog(params[1].replace(/^\s+/, ''));
|
||||
break;
|
||||
|
||||
case 'ROOMTOPIC':
|
||||
this.setText(MciViewIds.mrcChat.roomName, `#${params[1]}`);
|
||||
this.setText(MciViewIds.mrcChat.roomTopic, params[2]);
|
||||
|
||||
this.customFormatObj.roomName = params[1];
|
||||
this.customFormatObj.roomTopic = params[2];
|
||||
this.updateCustomViews();
|
||||
|
||||
this.state.room = params[1];
|
||||
break;
|
||||
|
||||
case 'USERLIST':
|
||||
this.state.nicks = params[1].split(',');
|
||||
|
||||
this.customFormatObj.roomUserCount = this.state.nicks.length;
|
||||
this.updateCustomViews();
|
||||
break;
|
||||
|
||||
case 'STATS': {
|
||||
const [
|
||||
boardCount,
|
||||
roomCount,
|
||||
userCount,
|
||||
activityLevel
|
||||
] = params[1].split(' ').map(v => parseInt(v));
|
||||
|
||||
const activityLevelIndicator = this.getActivityLevelIndicator(activityLevel);
|
||||
|
||||
Object.assign(
|
||||
this.customFormatObj,
|
||||
{
|
||||
boardCount, roomCount, userCount,
|
||||
activityLevel, activityLevelIndicator
|
||||
}
|
||||
);
|
||||
|
||||
this.setText(MciViewIds.mrcChat.mrcUsers, userCount);
|
||||
this.setText(MciViewIds.mrcChat.mrcBbses, boardCount);
|
||||
|
||||
this.updateCustomViews();
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
this.addMessageToChatLog(message.body);
|
||||
break;
|
||||
}
|
||||
|
||||
} else {
|
||||
if(message.body === this.state.lastSentMsg.msg) {
|
||||
this.customFormatObj.latencyMs =
|
||||
moment.duration(moment().diff(this.state.lastSentMsg.time)).asMilliseconds();
|
||||
delete this.state.lastSentMsg.msg;
|
||||
}
|
||||
|
||||
if (message.to_room == this.state.room) {
|
||||
// if we're here then we want to show it to the user
|
||||
const currentTime = moment().format(this.client.currentTheme.helpers.getTimeFormat());
|
||||
this.addMessageToChatLog('|08' + currentTime + '|00 ' + message.body + '|00');
|
||||
}
|
||||
}
|
||||
|
||||
this.viewControllers.mrcChat.switchFocus(MciViewIds.mrcChat.inputArea);
|
||||
});
|
||||
}
|
||||
|
||||
getActivityLevelIndicator(level) {
|
||||
let indicators = this.config.activityLevelIndicators;
|
||||
if(!Array.isArray(indicators) || indicators.length < level + 1) {
|
||||
indicators = [ ' ', '░', '▒', '▓' ];
|
||||
}
|
||||
return indicators[level];
|
||||
}
|
||||
|
||||
setText(mciId, text) {
|
||||
return this.setViewText('mrcChat', mciId, text);
|
||||
}
|
||||
|
||||
updateCustomViews() {
|
||||
return this.updateCustomViewTextsWithFilter(
|
||||
'mrcChat',
|
||||
MciViewIds.mrcChat.customRangeStart,
|
||||
this.customFormatObj
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Receives the message input from the user and does something with it based on what it is
|
||||
*/
|
||||
processOutgoingMessage(message, to_user) {
|
||||
if (message.startsWith('/')) {
|
||||
this.processSlashCommand(message);
|
||||
} else {
|
||||
if (message == '') {
|
||||
// don't do anything if message is blank, just update stats
|
||||
this.sendServerMessage('STATS');
|
||||
return;
|
||||
}
|
||||
|
||||
// else just format and send
|
||||
const textFormatObj = {
|
||||
fromUserName : this.state.alias,
|
||||
toUserName : to_user,
|
||||
message : message
|
||||
};
|
||||
|
||||
const messageFormat =
|
||||
this.config.messageFormat ||
|
||||
'|00|10<|02{fromUserName}|10>|00 |03{message}|00';
|
||||
|
||||
const privateMessageFormat =
|
||||
this.config.outgoingPrivateMessageFormat ||
|
||||
'|00|10<|02{fromUserName}|10|14->|02{toUserName}>|00 |03{message}|00';
|
||||
|
||||
let formattedMessage = '';
|
||||
if (to_user == undefined) {
|
||||
// normal message
|
||||
formattedMessage = stringFormat(messageFormat, textFormatObj);
|
||||
} else {
|
||||
// pm
|
||||
formattedMessage = stringFormat(privateMessageFormat, textFormatObj);
|
||||
}
|
||||
|
||||
try {
|
||||
this.state.lastSentMsg = {
|
||||
msg : formattedMessage,
|
||||
time : moment(),
|
||||
};
|
||||
this.sendMessageToMultiplexer(to_user || '', '', this.state.room, formattedMessage);
|
||||
} catch(e) {
|
||||
this.client.log.warn( { error : e.message }, 'MRC error');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a message that begins with a slash
|
||||
*/
|
||||
processSlashCommand(message) {
|
||||
const cmd = message.split(' ');
|
||||
cmd[0] = cmd[0].substr(1).toLowerCase();
|
||||
|
||||
switch (cmd[0]) {
|
||||
case 'pm':
|
||||
this.processOutgoingMessage(cmd[2], cmd[1]);
|
||||
break;
|
||||
|
||||
case 'rainbow': {
|
||||
// this is brutal, but i love it
|
||||
const line = message.replace(/^\/rainbow\s/, '').split(' ').reduce(function (a, c) {
|
||||
const cc = Math.floor((Math.random() * 31) + 1).toString().padStart(2, '0');
|
||||
a += `|${cc}${c}|00 `;
|
||||
return a;
|
||||
}, '').substr(0, 140).replace(/\\s\|\d*$/, '');
|
||||
|
||||
this.processOutgoingMessage(line);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'l33t':
|
||||
this.processOutgoingMessage(StringUtil.stylizeString(message.substr(6), 'l33t'));
|
||||
break;
|
||||
|
||||
case 'kewl': {
|
||||
const text_modes = Array('f','v','V','i','M');
|
||||
const mode = text_modes[Math.floor(Math.random() * text_modes.length)];
|
||||
this.processOutgoingMessage(StringUtil.stylizeString(message.substr(6), mode));
|
||||
break;
|
||||
}
|
||||
|
||||
case 'whoon':
|
||||
this.sendServerMessage('WHOON');
|
||||
break;
|
||||
|
||||
case 'motd':
|
||||
this.sendServerMessage('MOTD');
|
||||
break;
|
||||
|
||||
case 'meetups':
|
||||
this.sendServerMessage('MEETUPS');
|
||||
break;
|
||||
|
||||
case 'bbses':
|
||||
this.sendServerMessage('CONNECTED');
|
||||
break;
|
||||
|
||||
case 'topic':
|
||||
this.sendServerMessage(`NEWTOPIC:${this.state.room}:${message.substr(7)}`);
|
||||
break;
|
||||
|
||||
case 'info':
|
||||
this.sendServerMessage(`INFO ${cmd[1]}`);
|
||||
break;
|
||||
|
||||
case 'join':
|
||||
this.joinRoom(cmd[1]);
|
||||
break;
|
||||
|
||||
case 'chatters':
|
||||
this.sendServerMessage('CHATTERS');
|
||||
break;
|
||||
|
||||
case 'rooms':
|
||||
this.sendServerMessage('LIST');
|
||||
break;
|
||||
|
||||
case 'quit' :
|
||||
return this.prevMenu();
|
||||
|
||||
case 'clear':
|
||||
this.clearMessages();
|
||||
break;
|
||||
|
||||
case '?':
|
||||
this.addMessageToChatLog(helpText.split(/\n/g));
|
||||
break;
|
||||
|
||||
default:
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// just do something to get the cursor back to the right place ¯\_(ツ)_/¯
|
||||
// :TODO: fix me!
|
||||
this.sendServerMessage('STATS');
|
||||
}
|
||||
|
||||
clearMessages() {
|
||||
const chatLogView = this.viewControllers.mrcChat.getView(MciViewIds.mrcChat.chatLog);
|
||||
chatLogView.setText('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a json object, stringifies it and sends it to the MRC multiplexer
|
||||
*/
|
||||
sendMessageToMultiplexer(to_user, to_site, to_room, body) {
|
||||
|
||||
const message = {
|
||||
to_user,
|
||||
to_site,
|
||||
to_room,
|
||||
body,
|
||||
from_user : this.state.alias,
|
||||
from_room : this.state.room,
|
||||
};
|
||||
|
||||
if(this.state.socket) {
|
||||
this.state.socket.write(JSON.stringify(message) + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an MRC 'server' message
|
||||
*/
|
||||
sendServerMessage(command, to_site) {
|
||||
Log.debug({ module: 'mrc', command: command }, 'Sending server command');
|
||||
this.sendMessageToMultiplexer('SERVER', to_site || '', this.state.room, command);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a heartbeat to the MRC server
|
||||
*/
|
||||
sendHeartbeat() {
|
||||
this.sendServerMessage('IAMHERE');
|
||||
}
|
||||
|
||||
/**
|
||||
* Joins a room, unsurprisingly
|
||||
*/
|
||||
joinRoom(room) {
|
||||
// room names are displayed with a # but referred to without. confusing.
|
||||
room = room.replace(/^#/, '');
|
||||
this.state.room = room;
|
||||
this.sendServerMessage(`NEWROOM:${this.state.room}:${room}`);
|
||||
this.sendServerMessage('USERLIST');
|
||||
}
|
||||
|
||||
/**
|
||||
* Things that happen when a local user connects to the MRC multiplexer
|
||||
*/
|
||||
clientConnect() {
|
||||
this.sendServerMessage('MOTD');
|
||||
this.joinRoom('lobby');
|
||||
this.sendServerMessage('STATS');
|
||||
this.sendHeartbeat();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
|
@ -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();
|
||||
}
|
||||
};
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
);
|
||||
}
|
||||
};
|
|
@ -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));
|
||||
|
||||
|
|
|
@ -9,113 +9,174 @@ exports.getHelpFor = getHelpFor;
|
|||
const usageHelp = exports.USAGE_HELP = {
|
||||
General :
|
||||
`usage: oputil.js [--version] [--help]
|
||||
<command> [<args>]
|
||||
<command> [<arguments>]
|
||||
|
||||
global args:
|
||||
-c, --config PATH specify config path (${getDefaultConfigPath()})
|
||||
-n, --no-prompt assume defaults/don't prompt for input where possible
|
||||
Global arguments:
|
||||
-c, --config PATH Specify config path (default is ${getDefaultConfigPath()})
|
||||
-n, --no-prompt Assume defaults (don't prompt for input where possible)
|
||||
--verbose Verbose output, where applicable
|
||||
|
||||
commands:
|
||||
user user utilities
|
||||
config config file management
|
||||
fb file base management
|
||||
mb message base management
|
||||
Commands:
|
||||
user User management
|
||||
config Configuration management
|
||||
fb File base management
|
||||
mb Message base management
|
||||
`,
|
||||
User :
|
||||
`usage: oputil.js user <action> [<args>]
|
||||
`usage: oputil.js user <action> [<arguments>]
|
||||
|
||||
actions:
|
||||
info USERNAME display information about a user
|
||||
pw USERNAME PASSWORD set a user's password
|
||||
aliases: password, passwd
|
||||
rm USERNAME permanently removes user from system
|
||||
aliases: remove, delete, del
|
||||
activate USERNAME set status to active
|
||||
deactivate USERNAME set status to inactive
|
||||
disable USERNAME set status to disabled
|
||||
lock USERNAME set status to locked
|
||||
group USERNAME [+|-]GROUP adds (+) or removes (-) user from a group
|
||||
Actions:
|
||||
info USERNAME Display information about a user
|
||||
|
||||
pw USERNAME PASSWORD Set a user's password
|
||||
(passwd|password)
|
||||
|
||||
rm USERNAME Permanently removes user from system
|
||||
(del|delete|remove)
|
||||
|
||||
rename USERNAME NEWNAME Rename a user
|
||||
(mv)
|
||||
|
||||
2fa-otp USERNAME SPEC Enable 2FA/OTP for the user
|
||||
(otp)
|
||||
|
||||
The system supports various implementations of Two Factor Authentication (2FA)
|
||||
One Time Password (OTP) authentication.
|
||||
|
||||
Valid specs:
|
||||
disable : Removes 2FA/OTP from the user
|
||||
google : Google Authenticator
|
||||
hotp : HMAC-Based One-Time Password Algorithm (RFC-4266)
|
||||
totp : Time-Based One-Time Password Algorithm (RFC-6238)
|
||||
|
||||
activate USERNAME Set a user's status to "active"
|
||||
|
||||
deactivate USERNAME Set a user's status to "inactive"
|
||||
|
||||
disable USERNAME Set a user's status to "disabled"
|
||||
|
||||
lock USERNAME Set a user's status to "locked"
|
||||
|
||||
group USERNAME [+|-]GROUP Adds (+) or removes (-) user from a group
|
||||
|
||||
info arguments:
|
||||
--security Include security information in output
|
||||
|
||||
2fa-otp arguments:
|
||||
--qr-type TYPE Specify QR code type
|
||||
|
||||
Valid QR types:
|
||||
ascii : Plain ASCII (default)
|
||||
data : HTML data URL
|
||||
img : HTML image tag
|
||||
svg : SVG image
|
||||
|
||||
--out PATH Path to write QR code to. defaults to stdout
|
||||
`,
|
||||
|
||||
Config :
|
||||
`usage: oputil.js config <action> [<args>]
|
||||
`usage: oputil.js config <action> [<arguments>]
|
||||
|
||||
actions:
|
||||
new generate a new/initial configuration
|
||||
cat cat current configuration to stdout
|
||||
Actions:
|
||||
new Generate a new / default configuration
|
||||
|
||||
cat args:
|
||||
--no-color disable color
|
||||
--no-comments strip any comments
|
||||
cat Write current configuration to stdout
|
||||
|
||||
cat arguments:
|
||||
--no-color Disable color
|
||||
--no-comments Strip any comments
|
||||
`,
|
||||
FileBase :
|
||||
`usage: oputil.js fb <action> [<args>]
|
||||
`usage: oputil.js fb <action> [<arguments>]
|
||||
|
||||
actions:
|
||||
scan AREA_TAG[@STORAGE_TAG] scan specified area
|
||||
may also contain optional GLOB as last parameter,
|
||||
for example: scan some_area *.zip
|
||||
Actions:
|
||||
scan AREA_TAG[@STORAGE_TAG] Scan specified area
|
||||
|
||||
info CRITERIA display information about areas and/or files
|
||||
matching CRITERIA.
|
||||
May contain optional GLOB as last parameter.
|
||||
Example: ./oputil.js fb scan d0pew4r3z *.zip
|
||||
|
||||
mv SRC [SRC...] DST move entry(s) from SRC to DST
|
||||
SRC: FILENAME_WC|SHA|FILE_ID|AREA_TAG[@STORAGE_TAG]
|
||||
DST: AREA_TAG[@STORAGE_TAG]
|
||||
info CRITERIA Display information about areas and/or files
|
||||
|
||||
rm SRC [SRC...] remove entry(s) from the system matching SRC
|
||||
SRC: FILENAME_WC|SHA|FILE_ID|AREA_TAG[@STORAGE_TAG]
|
||||
desc CRITERIA sets a new file description for file base entry
|
||||
matching CRITERIA. Launches an external editor using
|
||||
$VISUAL, $EDITOR, or vim/notepad.
|
||||
import-areas FILEGATE.ZXX import file base areas using FileGate RAID type format
|
||||
mv SRC [SRC...] DST Move matching entry(s)
|
||||
(move)
|
||||
|
||||
scan args:
|
||||
--tags TAG1,TAG2,... specify tag(s) to assign to discovered entries
|
||||
Source may be any of the following:
|
||||
- Filename including '*' wildcards
|
||||
- SHA-1
|
||||
- File ID
|
||||
- Area tag with optional @storageTag suffix
|
||||
Destination is area tag with optional @storageTag suffix
|
||||
|
||||
--desc-file [PATH] prefer file descriptions from supplied path over other
|
||||
other sources such as FILE_ID.DIZ. Path must point to
|
||||
a valid FILES.BBS or DESCRIPT.ION file.
|
||||
--update attempt to update information for existing entries
|
||||
--quick perform quick scan
|
||||
rm SRC [SRC...] Remove entry(s) from the system
|
||||
(del|delete|remove)
|
||||
|
||||
info args:
|
||||
--show-desc display short description, if any
|
||||
Source may be any of the following:
|
||||
- Filename including '*' wildcards
|
||||
- SHA-1
|
||||
- File ID
|
||||
- Area tag with optional @storageTag suffix
|
||||
|
||||
remove args:
|
||||
--phys-file also remove underlying physical file
|
||||
desc CRITERIA Updates an file base entry's description
|
||||
|
||||
import-areas args:
|
||||
--type TYPE sets import areas type. valid options are "zxx" or "na"
|
||||
--create-dirs create backing storage directories
|
||||
Launches an external editor using $VISUAL, $EDITOR, or vim/notepad.
|
||||
|
||||
import-areas FILEGATE.ZXX Import file base areas using FileGate RAID type format
|
||||
|
||||
scan arguments:
|
||||
--tags TAG1,TAG2,... Specify hashtag(s) to assign to discovered entries
|
||||
|
||||
--desc-file [PATH] Prefer file descriptions from supplied input file
|
||||
|
||||
If a file description can be found in the supplied input file, prefer that description
|
||||
over other sources such related FILE_ID.DIZ. Path must point to a valid FILES.BBS or
|
||||
DESCRIPT.ION file.
|
||||
|
||||
--update Attempt to update information for existing entries
|
||||
--full Perform a full scan (default is quick)
|
||||
|
||||
info arguments:
|
||||
--show-desc Display short description, if any
|
||||
|
||||
remove arguments:
|
||||
--phys-file Also remove underlying physical file
|
||||
|
||||
import-areas arguments:
|
||||
--type TYPE Sets import areas type
|
||||
|
||||
Valid types are are "zxx" or "na".
|
||||
|
||||
--create-dirs Also create backing storage directories
|
||||
`,
|
||||
FileOpsInfo :
|
||||
`
|
||||
general information:
|
||||
AREA_TAG[@STORAGE_TAG] can specify an area tag and optionally, a storage specific tag
|
||||
example: retro@bbs
|
||||
General Information:
|
||||
Generally an area tag can also include an optional storage tag. For example, the
|
||||
area of 'bbswarez' stored using 'bbswarez_main': bbswarez@bbswarez_main
|
||||
|
||||
CRITERIA file base entry criteria. in general, can be AREA_TAG, SHA,
|
||||
FILE_ID, or FILENAME_WC.
|
||||
When performing an initial import of a large area or storage backing, --full
|
||||
is the best option. If re-scanning an area for updates a standard / quick scan is
|
||||
generally good enough.
|
||||
|
||||
FILENAME_WC filename with * and ? wildcard support. may match 0:n entries
|
||||
SHA full or partial SHA-256
|
||||
FILE_ID a file identifier. see file.sqlite3
|
||||
File ID's are those found in file.sqlite3.
|
||||
`,
|
||||
MessageBase :
|
||||
`usage: oputil.js mb <action> [<args>]
|
||||
`usage: oputil.js mb <action> [<arguments>]
|
||||
|
||||
actions:
|
||||
areafix CMD1 CMD2 ... ADDR sends an AreaFix NetMail to ADDR with the supplied command(s)
|
||||
one or more commands may be supplied. commands that are multi
|
||||
part such as "%COMPRESS ZIP" should be quoted.
|
||||
import-areas PATH import areas using fidonet *.NA or AREAS.BBS file from PATH
|
||||
Actions:
|
||||
areafix CMD1 CMD2 ... ADDR Sends an AreaFix NetMail
|
||||
|
||||
import-areas args:
|
||||
--conf CONF_TAG conference tag in which to import areas
|
||||
--network NETWORK network name/key to associate FTN areas
|
||||
--uplinks UL1,UL2,... one or more comma separated uplinks
|
||||
--type TYPE area import type. valid options are "bbs" and "na"
|
||||
NetMail is sent to supplied address with the supplied command(s). Multi-part commands
|
||||
such as "%COMPRESS ZIP" should be quoted.
|
||||
|
||||
import-areas PATH Import areas using FidoNet *.NA or AREAS.BBS file
|
||||
|
||||
import-areas arguments:
|
||||
--conf CONF_TAG Conference tag in which to import areas
|
||||
--network NETWORK Network name/key to associate FTN areas
|
||||
--uplinks UL1,UL2,... One or more uplinks (comma separated)
|
||||
--type TYPE Area import type
|
||||
|
||||
Valid types are "bbs" and "na".
|
||||
`
|
||||
};
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
|
@ -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, '');
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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' );
|
||||
}
|
||||
|
|
|
@ -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' );
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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!
|
||||
|
|
|
@ -27,6 +27,7 @@ exports.getAvailableThemes = getAvailableThemes;
|
|||
exports.getRandomTheme = getRandomTheme;
|
||||
exports.setClientTheme = setClientTheme;
|
||||
exports.initAvailableThemes = initAvailableThemes;
|
||||
exports.displayPreparedArt = displayPreparedArt;
|
||||
exports.displayThemeArt = displayThemeArt;
|
||||
exports.displayThemedPause = displayThemedPause;
|
||||
exports.displayThemedPrompt = displayThemedPrompt;
|
||||
|
@ -439,7 +440,7 @@ function getThemeArt(options, cb) {
|
|||
// :TODO: replace asAnsi stuff with something like retrieveAs = 'ansi' | 'pipe' | ...
|
||||
// :TODO: Some of these options should only be set if not provided!
|
||||
options.asAnsi = true; // always convert to ANSI
|
||||
options.readSauce = true; // read SAUCE, if avail
|
||||
options.readSauce = _.get(options, 'readSauce', true); // read SAUCE, if avail
|
||||
options.random = _.get(options, 'random', true); // FILENAME<n>.EXT support
|
||||
|
||||
//
|
||||
|
@ -510,6 +511,17 @@ function getThemeArt(options, cb) {
|
|||
);
|
||||
}
|
||||
|
||||
function displayPreparedArt(options, artInfo, cb) {
|
||||
const displayOpts = {
|
||||
sauce : artInfo.sauce,
|
||||
font : options.font,
|
||||
trailingLF : options.trailingLF,
|
||||
};
|
||||
art.display(options.client, artInfo.data, displayOpts, (err, mciMap, extraInfo) => {
|
||||
return cb(err, { mciMap : mciMap, artInfo : artInfo, extraInfo : extraInfo } );
|
||||
});
|
||||
}
|
||||
|
||||
function displayThemeArt(options, cb) {
|
||||
assert(_.isObject(options));
|
||||
assert(_.isObject(options.client));
|
||||
|
@ -537,14 +549,7 @@ function displayThemeArt(options, cb) {
|
|||
}
|
||||
},
|
||||
function disp(artInfo, callback) {
|
||||
const displayOpts = {
|
||||
sauce : artInfo.sauce,
|
||||
font : options.font,
|
||||
trailingLF : options.trailingLF,
|
||||
};
|
||||
art.display(options.client, artInfo.data, displayOpts, (err, mciMap, extraInfo) => {
|
||||
return callback(err, { mciMap : mciMap, artInfo : artInfo, extraInfo : extraInfo } );
|
||||
});
|
||||
return displayPreparedArt(options, artInfo, callback);
|
||||
}
|
||||
],
|
||||
(err, artData) => {
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 } );
|
||||
|
|
116
core/user.js
116
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);
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
}
|
|
@ -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) } );
|
||||
}
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
};
|
|
@ -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!
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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
|
||||
};
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
);
|
||||
}
|
12
core/view.js
12
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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -24,6 +24,7 @@
|
|||
- [Colour Codes]({{ site.baseurl }}{% link configuration/colour-codes.md %})
|
||||
- [Access Condition System (ACS)]({{ site.baseurl }}{% link configuration/acs.md %})
|
||||
- [Event Scheduler]({{ site.baseurl }}{% link configuration/event-scheduler.md %})
|
||||
- [Security]({{ site.baseurl }}{% link configuration/security.md %})
|
||||
|
||||
- File Base
|
||||
- [About]({{ site.baseurl }}{% link filebase/index.md %})
|
||||
|
@ -81,6 +82,8 @@
|
|||
- [Set Newscan Date]({{ site.baseurl }}{% link modding/set-newscan-date.md %})
|
||||
- [Node to Node Messaging]({{ site.baseurl }}{% link modding/node-msg.md %})
|
||||
- [Top X]({{ site.baseurl }}{% link modding/top-x.md %})
|
||||
- [2FA/OTP Config]({{ site.baseurl }}{% link modding/user-2fa-otp-config.md %})
|
||||
- [Auto Signature Editor]({{ site.baseurl }}{% link modding/autosig-edit.md %})
|
||||
|
||||
- Administration
|
||||
- [oputil]({{ site.baseurl }}{% link admin/oputil.md %})
|
||||
|
|
|
@ -9,17 +9,18 @@ Let's look the main help output as per this writing:
|
|||
|
||||
```
|
||||
usage: oputil.js [--version] [--help]
|
||||
<command> [<args>]
|
||||
<command> [<arguments>]
|
||||
|
||||
global args:
|
||||
-c, --config PATH specify config path (./config/)
|
||||
-n, --no-prompt assume defaults/don't prompt for input where possible
|
||||
Global arguments:
|
||||
-c, --config PATH Specify config path (default is ./config/)
|
||||
-n, --no-prompt Assume defaults (don't prompt for input where possible)
|
||||
--verbose Verbose output, where applicable
|
||||
|
||||
commands:
|
||||
user user utilities
|
||||
config config file management
|
||||
fb file base management
|
||||
mb message base management
|
||||
Commands:
|
||||
user User management
|
||||
config Configuration management
|
||||
fb File base management
|
||||
mb Message base management
|
||||
```
|
||||
|
||||
Commands break up operations by groups:
|
||||
|
@ -41,19 +42,55 @@ Type `./oputil.js <command> --help` for additional help on a particular command.
|
|||
The `user` command covers various user operations.
|
||||
|
||||
```
|
||||
usage: oputil.js user <action> [<args>]
|
||||
usage: oputil.js user <action> [<arguments>]
|
||||
|
||||
actions:
|
||||
info USERNAME display information about a user
|
||||
pw USERNAME PASSWORD set a user's password
|
||||
aliases: password, passwd
|
||||
rm USERNAME permanently removes user from system
|
||||
aliases: remove, delete, del
|
||||
activate USERNAME set status to active
|
||||
deactivate USERNAME set status to inactive
|
||||
disable USERNAME set status to disabled
|
||||
lock USERNAME set status to locked
|
||||
group USERNAME [+|-]GROUP adds (+) or removes (-) user from a group
|
||||
Actions:
|
||||
info USERNAME Display information about a user
|
||||
|
||||
pw USERNAME PASSWORD Set a user's password
|
||||
(passwd|password)
|
||||
|
||||
rm USERNAME Permanently removes user from system
|
||||
(del|delete|remove)
|
||||
|
||||
rename USERNAME NEWNAME Rename a user
|
||||
(mv)
|
||||
|
||||
2fa-otp USERNAME SPEC Enable 2FA/OTP for the user
|
||||
(otp)
|
||||
|
||||
The system supports various implementations of Two Factor Authentication (2FA)
|
||||
One Time Password (OTP) authentication.
|
||||
|
||||
Valid specs:
|
||||
disable : Removes 2FA/OTP from the user
|
||||
google : Google Authenticator
|
||||
hotp : HMAC-Based One-Time Password Algorithm (RFC-4266)
|
||||
totp : Time-Based One-Time Password Algorithm (RFC-6238)
|
||||
|
||||
activate USERNAME Set a user's status to "active"
|
||||
|
||||
deactivate USERNAME Set a user's status to "inactive"
|
||||
|
||||
disable USERNAME Set a user's status to "disabled"
|
||||
|
||||
lock USERNAME Set a user's status to "locked"
|
||||
|
||||
group USERNAME [+|-]GROUP Adds (+) or removes (-) user from a group
|
||||
|
||||
info arguments:
|
||||
--security Include security information in output
|
||||
|
||||
2fa-otp arguments:
|
||||
--qr-type TYPE Specify QR code type
|
||||
|
||||
Valid QR types:
|
||||
ascii : Plain ASCII (default)
|
||||
data : HTML data URL
|
||||
img : HTML image tag
|
||||
svg : SVG image
|
||||
|
||||
--out PATH Path to write QR code to. defaults to stdout
|
||||
```
|
||||
|
||||
| Action | Description | Examples | Aliases |
|
||||
|
@ -61,25 +98,31 @@ actions:
|
|||
| `info` | Display user information| `./oputil.js user info joeuser` | N/A |
|
||||
| `pw` | Set password | `./oputil.js user pw joeuser s3cr37` | `passwd`, `password` |
|
||||
| `rm` | Removes user | `./oputil.js user del joeuser` | `remove`, `del`, `delete` |
|
||||
| `rename` | Renames a user | `./oputil.js user rename joeuser joe` | `mv` |
|
||||
| `2fa-otp` | Manage 2FA/OTP for a user | `./oputil.js user 2fa-otp joeuser googleAuth` | `otp`
|
||||
| `activate` | Activates user | `./oputil.js user activate joeuser` | N/A |
|
||||
| `deactivate` | Deactivates user | `./oputil.js user deactivate joeuser` | N/A |
|
||||
| `disable` | Disables user (user will not be able to login) | `./oputil.js user disable joeuser` | N/A |
|
||||
| `lock` | Locks the user account (prevents logins) | `./oputil.js user lock joeuser` | N/A |
|
||||
| `group` | Modifies users group membership | Add to group: `./oputil.js user group joeuser +derp`<br/>Remove from group: `./oputil.js user group joeuser -derp` | N/A |
|
||||
|
||||
#### Manage 2FA/OTP
|
||||
While `oputil.js` can be used to manage a user's 2FA/OTP, it is highly recommended to require users to opt-in themselves. See [Security](/docs/configuration/security.md) for details.
|
||||
|
||||
## Configuration
|
||||
The `config` command allows sysops to perform various system configuration and maintenance tasks.
|
||||
|
||||
```
|
||||
usage: oputil.js config <action> [<args>]
|
||||
usage: oputil.js config <action> [<arguments>]
|
||||
|
||||
actions:
|
||||
new generate a new/initial configuration
|
||||
cat cat current configuration to stdout
|
||||
Actions:
|
||||
new Generate a new / default configuration
|
||||
|
||||
cat args:
|
||||
--no-color disable color
|
||||
--no-comments strip any comments
|
||||
cat Write current configuration to stdout
|
||||
|
||||
cat arguments:
|
||||
--no-color Disable color
|
||||
--no-comments Strip any comments
|
||||
```
|
||||
|
||||
| Action | Description | Examples |
|
||||
|
@ -91,56 +134,75 @@ cat args:
|
|||
The `fb` command provides a powerful file base management interface.
|
||||
|
||||
```
|
||||
usage: oputil.js fb <action> [<args>]
|
||||
usage: oputil.js fb <action> [<arguments>]
|
||||
|
||||
actions:
|
||||
scan AREA_TAG[@STORAGE_TAG] scan specified area
|
||||
may also contain optional GLOB as last parameter,
|
||||
for example: scan some_area *.zip
|
||||
Actions:
|
||||
scan AREA_TAG[@STORAGE_TAG] Scan specified area
|
||||
|
||||
info CRITERIA display information about areas and/or files
|
||||
matching CRITERIA.
|
||||
May contain optional GLOB as last parameter.
|
||||
Example: ./oputil.js fb scan d0pew4r3z *.zip
|
||||
|
||||
mv SRC [SRC...] DST move entry(s) from SRC to DST
|
||||
SRC: FILENAME_WC|SHA|FILE_ID|AREA_TAG[@STORAGE_TAG]
|
||||
DST: AREA_TAG[@STORAGE_TAG]
|
||||
info CRITERIA Display information about areas and/or files
|
||||
|
||||
rm SRC [SRC...] remove entry(s) from the system matching SRC
|
||||
SRC: FILENAME_WC|SHA|FILE_ID|AREA_TAG[@STORAGE_TAG]
|
||||
desc CRITERIA sets a new file description for file base entry
|
||||
matching CRITERIA. Launches an external editor using
|
||||
$VISUAL, $EDITOR, or vim/notepad.
|
||||
import-areas FILEGATE.ZXX import file base areas using FileGate RAID type format
|
||||
mv SRC [SRC...] DST Move matching entry(s)
|
||||
(move)
|
||||
|
||||
scan args:
|
||||
--tags TAG1,TAG2,... specify tag(s) to assign to discovered entries
|
||||
Source may be any of the following:
|
||||
- Filename including '*' wildcards
|
||||
- SHA-1
|
||||
- File ID
|
||||
- Area tag with optional @storageTag suffix
|
||||
Destination is area tag with optional @storageTag suffix
|
||||
|
||||
--desc-file [PATH] prefer file descriptions from supplied path over other
|
||||
other sources such as FILE_ID.DIZ. Path must point to
|
||||
a valid FILES.BBS or DESCRIPT.ION file.
|
||||
--update attempt to update information for existing entries
|
||||
--quick perform quick scan
|
||||
rm SRC [SRC...] Remove entry(s) from the system
|
||||
(del|delete|remove)
|
||||
|
||||
info args:
|
||||
--show-desc display short description, if any
|
||||
Source may be any of the following:
|
||||
- Filename including '*' wildcards
|
||||
- SHA-1
|
||||
- File ID
|
||||
- Area tag with optional @storageTag suffix
|
||||
|
||||
remove args:
|
||||
--phys-file also remove underlying physical file
|
||||
desc CRITERIA Updates an file base entry's description
|
||||
|
||||
import-areas args:
|
||||
--type TYPE sets import areas type. valid options are "zxx" or "na"
|
||||
--create-dirs create backing storage directories
|
||||
Launches an external editor using $VISUAL, $EDITOR, or vim/notepad.
|
||||
|
||||
general information:
|
||||
AREA_TAG[@STORAGE_TAG] can specify an area tag and optionally, a storage specific tag
|
||||
example: retro@bbs
|
||||
import-areas FILEGATE.ZXX Import file base areas using FileGate RAID type format
|
||||
|
||||
CRITERIA file base entry criteria. in general, can be AREA_TAG, SHA,
|
||||
FILE_ID, or FILENAME_WC.
|
||||
scan arguments:
|
||||
--tags TAG1,TAG2,... Specify hashtag(s) to assign to discovered entries
|
||||
|
||||
FILENAME_WC filename with * and ? wildcard support. may match 0:n entries
|
||||
SHA full or partial SHA-256
|
||||
FILE_ID a file identifier. see file.sqlite3
|
||||
--desc-file [PATH] Prefer file descriptions from supplied input file
|
||||
|
||||
If a file description can be found in the supplied input file, prefer that description
|
||||
over other sources such related FILE_ID.DIZ. Path must point to a valid FILES.BBS or
|
||||
DESCRIPT.ION file.
|
||||
|
||||
--update Attempt to update information for existing entries
|
||||
--full Perform a full scan (default is quick)
|
||||
|
||||
info arguments:
|
||||
--show-desc Display short description, if any
|
||||
|
||||
remove arguments:
|
||||
--phys-file Also remove underlying physical file
|
||||
|
||||
import-areas arguments:
|
||||
--type TYPE Sets import areas type
|
||||
|
||||
Valid types are are "zxx" or "na".
|
||||
|
||||
--create-dirs Also create backing storage directories
|
||||
|
||||
General Information:
|
||||
Generally an area tag can also include an optional storage tag. For example, the
|
||||
area of 'bbswarez' stored using 'bbswarez_main': bbswarez@bbswarez_main
|
||||
|
||||
When performing an initial import of a large area or storage backing, --full
|
||||
is the best option. If re-scanning an area for updates a standard / quick scan is
|
||||
generally good enough.
|
||||
|
||||
File ID's are those found in file.sqlite3.
|
||||
```
|
||||
|
||||
#### Scan File Area
|
||||
|
@ -149,7 +211,8 @@ The `scan` action can (re)scan a file area for new entries as well as update (`-
|
|||
##### Examples
|
||||
Performing a quick scan of a specific area's storage location ("retro_warez", "retro_warez_games) matching only *.zip extensions:
|
||||
```bash
|
||||
$ ./oputil.js fb scan --quick retro_warez@retro_warez_games *.zip`
|
||||
# note that we must quote the wildcard to prevent shell expansion
|
||||
$ ./oputil.js fb scan --quick retro_warez@retro_warez_games "*.zip"`
|
||||
```
|
||||
|
||||
Update all entries in the "artscene" area supplying the file tags "artscene", and "textmode".
|
||||
|
@ -218,19 +281,23 @@ The above command will process FILEGATE.ZXX creating areas and backing directori
|
|||
The `mb` command provides various Message Base related tools:
|
||||
|
||||
```
|
||||
usage: oputil.js mb <action> [<args>]
|
||||
usage: oputil.js mb <action> [<arguments>]
|
||||
|
||||
actions:
|
||||
areafix CMD1 CMD2 ... ADDR sends an AreaFix NetMail to ADDR with the supplied command(s)
|
||||
one or more commands may be supplied. commands that are multi
|
||||
part such as "%COMPRESS ZIP" should be quoted.
|
||||
import-areas PATH import areas using fidonet *.NA or AREAS.BBS file from PATH
|
||||
Actions:
|
||||
areafix CMD1 CMD2 ... ADDR Sends an AreaFix NetMail
|
||||
|
||||
import-areas args:
|
||||
--conf CONF_TAG conference tag in which to import areas
|
||||
--network NETWORK network name/key to associate FTN areas
|
||||
--uplinks UL1,UL2,... one or more comma separated uplinks
|
||||
--type TYPE area import type. valid options are "bbs" and "na"
|
||||
NetMail is sent to supplied address with the supplied command(s). Multi-part commands
|
||||
such as "%COMPRESS ZIP" should be quoted.
|
||||
|
||||
import-areas PATH Import areas using FidoNet *.NA or AREAS.BBS file
|
||||
|
||||
import-areas arguments:
|
||||
--conf CONF_TAG Conference tag in which to import areas
|
||||
--network NETWORK Network name/key to associate FTN areas
|
||||
--uplinks UL1,UL2,... One or more uplinks (comma separated)
|
||||
--type TYPE Area import type
|
||||
|
||||
Valid types are "bbs" and "na"
|
||||
```
|
||||
|
||||
| Action | Description | Examples |
|
||||
|
|
|
@ -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 |
|
||||
|
|
|
@ -37,8 +37,8 @@ The following are ACS codes available as of this writing:
|
|||
| MM<i>minutes</i> | It is currently >= _minutes_ past midnight (system time) |
|
||||
| AC<i>achievementCount</i> | User has >= _achievementCount_ achievements |
|
||||
| AP<i>achievementPoints</i> | User has >= _achievementPoints_ achievement points |
|
||||
|
||||
\* Many more ACS codes are planned for the near future.
|
||||
| AF<i>authFactor</i> | User's current *Authentication Factor* is >= _authFactor_. Authentication factor 1 refers to username + password (or PubKey) while factor 2 refers to 2FA such as One-Time-Password authentication. |
|
||||
| AR<i>authFactorReq</i> | Current user **requires** an Authentication Factor >= _authFactorReq_ |
|
||||
|
||||
## ACS Strings
|
||||
ACS strings are one or more ACS codes in addition to some basic language semantics.
|
||||
|
|
|
@ -11,9 +11,15 @@ Archivers are manged via the `archives:archivers` configuration block of `config
|
|||
The following archivers are pre-configured in ENiGMA½ as of this writing. Remember that you can override settings or add new handlers!
|
||||
|
||||
#### ZZip
|
||||
* Formats: .7z, .bzip2, .zip, .gzip/.gz, and more
|
||||
* Formats: .7z, .bzip2, .gzip/.gz, and more
|
||||
* Key: `7Zip`
|
||||
* Homepage/package: [7-zip.org](http://www.7-zip.org/). Generally obtained from a `p7zip` package in UNIX-like environments. See http://p7zip.sourceforge.net/ for details.
|
||||
* Notes: Versions previous to 0.0.10-alpha defaulted to using 7zip for .zip files as well, but newer versions of the package give "volume" errors at times. See InfoZip below.
|
||||
|
||||
#### InfoZip
|
||||
* Formats: .zip
|
||||
* Key: InfoZip
|
||||
* Homepage/package: http://infozip.sourceforge.net/. Often already available in Linux. You will need `zip` and `unzip` in ENiGMA's path.
|
||||
|
||||
#### Lha
|
||||
* Formats: <a href="https://en.wikipedia.org/wiki/LHA_(file_format)">LHA</a> files such as .lzh.
|
||||
|
|
|
@ -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:
|
||||
```
|
||||
|
|
|
@ -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
|
||||
}
|
||||
]
|
||||
// ...
|
||||
}
|
||||
```
|
|
@ -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.
|
||||
|
|
|
@ -21,7 +21,8 @@ Each conference is represented by a entry under `messageConferences`. Each entri
|
|||
|
||||
### ACS
|
||||
An optional standard [ACS](/docs/configuration/acs.md) block can be supplied with the following rules:
|
||||
* `read`: ACS require to read (see) this conference. Defaults to `GM[users]`.
|
||||
* `read`: ACS required to read (see) this conference. Defaults to `GM[users]`.
|
||||
* `write`: ACS required to write (post) to this conference. Defaults to `GM[users]`.
|
||||
|
||||
### Example
|
||||
|
||||
|
@ -51,10 +52,12 @@ Message Areas are topic specific containers for messages that live within a part
|
|||
| `sort` | :-1: | Set to a number to override the default alpha-numeric sort order based on the `name` field. |
|
||||
| `default` | :-1: | Specify `true` to make this the default area (e.g. assigned to new users) |
|
||||
| `acs` | :-1: | A standard [ACS](/docs/configuration/acs.md) block. See **ACS** below. |
|
||||
| `autoSignatures` | :-1: | Set to `false` to disable auto-signatures in this area. |
|
||||
|
||||
### ACS
|
||||
An optional standard [ACS](/docs/configuration/acs.md) block can be supplied with the following rules:
|
||||
* `read`: ACS require to read (see) this conference. Defaults to `GM[users]`.
|
||||
* `read`: ACS required to read (see) this area. Defaults to `GM[users]`.
|
||||
* `write`: ACS required to write (post) to this area. Defaults to `GM[users]`.
|
||||
|
||||
### Example
|
||||
|
||||
|
@ -63,13 +66,14 @@ messageConferences: {
|
|||
local: {
|
||||
// ... see above ...
|
||||
areas: {
|
||||
enigma_dev: { // Area tag - required elsewhere!
|
||||
enigma_dev: { // Area tag - required elsewhere!
|
||||
name: ENiGMA 1/2 Development
|
||||
desc: ENiGMA 1/2 discussion!
|
||||
desc: ENiGMA 1/2 development and discussion!
|
||||
sort: 1
|
||||
default: true
|
||||
acs: {
|
||||
read: GM[users] // default
|
||||
write: GM[l33t] // super elite ENiGMA 1/2 users!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
```
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
<b>%USERNAME%</b>:<br>
|
||||
<br>
|
||||
<p>
|
||||
You have requested to enable 2-Factor Authentication via One-Time-Password for your account on <b>%BOARDNAME%</b>.<br>
|
||||
</p>
|
||||
<p>
|
||||
If this was not you, please ignore this email and <u>consider changing your password</u>. Otherwise, <a href="%REGISTER_URL%">please follow this link</a> or copy and paste the link below:<br><br>
|
||||
%REGISTER_URL%<br>
|
||||
</p>
|
||||
|
||||
|
|
@ -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%
|
48
package.json
48
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 <bryan@l33t.codes>",
|
||||
"license": "BSD-2-Clause",
|
||||
|
@ -22,38 +22,40 @@
|
|||
"retro"
|
||||
],
|
||||
"dependencies": {
|
||||
"async": "^2.6.1",
|
||||
"binary-parser": "^1.3.2",
|
||||
"async": "3.1.0",
|
||||
"binary-parser": "^1.5.0",
|
||||
"buffers": "github:NuSkooler/node-buffers",
|
||||
"bunyan": "^1.8.12",
|
||||
"exiftool": "^0.0.3",
|
||||
"fs-extra": "^7.0.1",
|
||||
"glob": "^7.1.2",
|
||||
"graceful-fs": "^4.1.15",
|
||||
"hashids": "^1.1.1",
|
||||
"hjson": "^3.1.2",
|
||||
"iconv-lite": "^0.4.23",
|
||||
"inquirer": "^6.2.1",
|
||||
"fs-extra": "8.1.0",
|
||||
"glob": "7.1.6",
|
||||
"graceful-fs": "^4.2.3",
|
||||
"hashids": "^2.0.1",
|
||||
"hjson": "^3.2.1",
|
||||
"iconv-lite": "0.5.0",
|
||||
"inquirer": "^7.0.0",
|
||||
"later": "1.2.0",
|
||||
"lodash": "^4.17.10",
|
||||
"lodash": "^4.17.15",
|
||||
"lru-cache": "^5.1.1",
|
||||
"mime-types": "^2.1.21",
|
||||
"minimist": "1.2.x",
|
||||
"mime-types": "2.1.24",
|
||||
"minimist": "1.2.0",
|
||||
"moment": "^2.24.0",
|
||||
"nntp-server": "^1.0.3",
|
||||
"node-pty": "^0.8.1",
|
||||
"nodemailer": "^5.1.1",
|
||||
"node-pty": "^0.9.0",
|
||||
"nodemailer": "^6.3.1",
|
||||
"otplib": "11.0.1",
|
||||
"qrcode-generator": "^1.4.4",
|
||||
"rlogin": "^1.0.0",
|
||||
"sane": "^4.0.2",
|
||||
"sanitize-filename": "^1.6.1",
|
||||
"sqlite3": "^4.0.6",
|
||||
"sane": "4.1.0",
|
||||
"sanitize-filename": "^1.6.3",
|
||||
"sqlite3": "^4.1.0",
|
||||
"sqlite3-trans": "^1.2.1",
|
||||
"ssh2": "^0.8.2",
|
||||
"ssh2": "0.8.6",
|
||||
"temptmp": "^1.1.0",
|
||||
"uuid": "^3.2.1",
|
||||
"uuid-parse": "^1.0.0",
|
||||
"ws": "^6.1.3",
|
||||
"xxhash": "^0.2.4",
|
||||
"uuid": "^3.3.3",
|
||||
"uuid-parse": "1.1.0",
|
||||
"ws": "^7.2.0",
|
||||
"xxhash": "^0.3.0",
|
||||
"yazl": "^2.5.1"
|
||||
},
|
||||
"devDependencies": {},
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Enable 2FA/OTP — ENiGMA½ BBS</title>
|
||||
<meta name="description" content="Enable 2-Factor Authentication via One-Time-Password">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
</head>
|
||||
<body>
|
||||
<p>
|
||||
Your OTP secret:<br>
|
||||
<b>%SECRET%</b>
|
||||
</p>
|
||||
<script>
|
||||
if('googleAuth' == '%OTP_TYPE%') {
|
||||
document.write('QR Code:<br>');
|
||||
document.write('<img src="%QR_IMG_DATA%"/>');
|
||||
}
|
||||
</script>
|
||||
<form action="%POST_URL%" method="post">
|
||||
<legend>Confirm One-Time-Password to continue:</legend>
|
||||
<input type="text" placeholder="One Time Password" id="otp" name="otp" required>
|
||||
<input type="hidden" value="%TOKEN%" name="token">
|
||||
<input type="hidden" value="%OTP_TYPE%" name="otpType">
|
||||
<input type="hidden" value="%SECRET%" name="secret">
|
||||
<button type="submit">Confirm</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
448
yarn.lock
448
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"
|
||||
|
|
Loading…
Reference in New Issue